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 json
import os import os
import random from typing import Any, Dict, List, Optional
from dataclasses import dataclass, field
from typing import Any, Dict, Iterable, List, Optional
from abogen.constants import LANGUAGE_DESCRIPTIONS, VOICES_INTERNAL from abogen.constants import LANGUAGE_DESCRIPTIONS
from abogen.utils import get_user_config_path from abogen.utils import get_user_config_path
_CONFIG_WRAPPER_KEY = "abogen_speaker_configs" _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: for code in voice_languages:
if isinstance(code, str) and code: if isinstance(code, str) and code:
normalized_langs.append(code.lower()) normalized_langs.append(code.lower())
randomize = bool(entry.get("randomize"))
resolved_voice = entry.get("resolved_voice") or voice_formula or voice resolved_voice = entry.get("resolved_voice") or voice_formula or voice
resolved_label = label or entry.get("id") or "" resolved_label = label or entry.get("id") or ""
slug = entry.get("id") if isinstance(entry.get("id"), str) else slugify_label(resolved_label) 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 "", "voice_formula": voice_formula if isinstance(voice_formula, str) else "",
"resolved_voice": resolved_voice if isinstance(resolved_voice, str) else "", "resolved_voice": resolved_voice if isinstance(resolved_voice, str) else "",
"languages": normalized_langs, "languages": normalized_langs,
"randomize": randomize,
} }
@@ -161,68 +157,6 @@ def list_configs() -> List[Dict[str, Any]]:
return ordered 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: def describe_language(code: str) -> str:
code = (code or "a").lower() code = (code or "a").lower()
return LANGUAGE_DESCRIPTIONS.get(code, code.upper()) return LANGUAGE_DESCRIPTIONS.get(code, code.upper())
+18 -51
View File
@@ -64,7 +64,6 @@ from abogen.speaker_configs import (
get_config, get_config,
list_configs, list_configs,
load_configs, load_configs,
random_voice,
save_configs, save_configs,
upsert_config, upsert_config,
slugify_label, slugify_label,
@@ -142,22 +141,34 @@ def _build_speaker_roster(
payload = speakers.get(speaker_id, {}) payload = speakers.get(speaker_id, {})
if speaker_id == "narrator": if speaker_id == "narrator":
continue continue
if payload.get("suppressed"):
continue continue
previous = existing_map.get(speaker_id) previous = existing_map.get(speaker_id)
roster[speaker_id] = { roster[speaker_id] = {
"id": speaker_id, "id": speaker_id,
"label": payload.get("label") or speaker_id.replace("_", " ").title(), "label": payload.get("label") or speaker_id.replace("_", " ").title(),
"voice": base_voice,
"analysis_confidence": payload.get("confidence"), "analysis_confidence": payload.get("confidence"),
"analysis_count": payload.get("count"), "analysis_count": payload.get("count"),
"gender": payload.get("gender", "unknown"), "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): if isinstance(previous, Mapping):
for key in ("voice", "voice_profile", "voice_formula", "resolved_voice", "pronunciation"): for key in ("voice", "voice_profile", "voice_formula", "resolved_voice", "pronunciation"):
value = previous.get(key) value = previous.get(key)
if value is not None and value != "": if value is not None and value != "":
roster[speaker_id][key] = 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 return roster
@@ -187,7 +198,6 @@ def _apply_speaker_config_to_roster(
roster: Mapping[str, Any], roster: Mapping[str, Any],
config: Optional[Mapping[str, Any]], config: Optional[Mapping[str, Any]],
*, *,
allow_randomize: bool = False,
persist_changes: bool = False, persist_changes: bool = False,
fallback_languages: Optional[Iterable[str]] = None, fallback_languages: Optional[Iterable[str]] = None,
) -> Tuple[Dict[str, Any], List[str], Optional[Dict[str, Any]]]: ) -> 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 "" 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} used_voices = {entry.get("resolved_voice") or entry.get("voice") for entry in updated_roster.values()} - {None}
narrator_voice = "" narrator_voice = ""
narrator_entry = updated_roster.get("narrator") if isinstance(updated_roster, Mapping) else None narrator_entry = updated_roster.get("narrator") if isinstance(updated_roster, Mapping) else None
if isinstance(narrator_entry, Mapping): if isinstance(narrator_entry, Mapping):
narrator_voice = str( narrator_voice = str(
narrator_entry.get("resolved_voice") narrator_entry.get("resolved_voice")
or narrator_entry.get("voice")
or narrator_entry.get("default_voice") or narrator_entry.get("default_voice")
or "" or ""
).strip() ).strip()
@@ -251,25 +259,8 @@ def _apply_speaker_config_to_roster(
voice_formula = str(config_entry.get("voice_formula") or "").strip() voice_formula = str(config_entry.get("voice_formula") or "").strip()
resolved_voice = str(config_entry.get("resolved_voice") 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 [] 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") chosen_voice = resolved_voice or voice_formula or voice_id or roster_entry.get("voice")
usable_languages = languages or allowed_languages 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: if chosen_voice:
roster_entry["resolved_voice"] = chosen_voice roster_entry["resolved_voice"] = chosen_voice
@@ -298,7 +289,6 @@ def _apply_speaker_config_to_roster(
"voice_formula": voice_formula, "voice_formula": voice_formula,
"resolved_voice": roster_entry.get("resolved_voice", resolved_voice or voice_id), "resolved_voice": roster_entry.get("resolved_voice", resolved_voice or voice_id),
"languages": usable_languages, "languages": usable_languages,
"randomize": randomize_flag,
} }
new_config = new_config_payload if (persist_changes and config_changed) else None 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 = (form.get(prefix + "voice") or "").strip()
voice_profile = (form.get(prefix + "profile") or "").strip() voice_profile = (form.get(prefix + "profile") or "").strip()
voice_formula = (form.get(prefix + "formula") 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) speaker_id = (form.get(prefix + "id") or "").strip() or slugify_label(label)
speakers[speaker_id] = { speakers[speaker_id] = {
"id": 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, "voice_formula": voice_formula,
"resolved_voice": voice_formula or voice, "resolved_voice": voice_formula or voice,
"languages": [], "languages": [],
"randomize": randomize_flag,
} }
payload = { payload = {
@@ -459,7 +447,6 @@ def _prepare_speaker_metadata(
run_analysis: bool = True, run_analysis: bool = True,
speaker_config: Optional[Mapping[str, Any]] = None, speaker_config: Optional[Mapping[str, Any]] = None,
apply_config: bool = False, apply_config: bool = False,
allow_randomize: bool = False,
persist_config: bool = False, persist_config: bool = False,
) -> tuple[List[Dict[str, Any]], Dict[str, Any], Dict[str, Any], List[str], Optional[Dict[str, Any]]]: ) -> 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] 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, applied_languages, updated_config = _apply_speaker_config_to_roster(
roster, roster,
speaker_config, speaker_config,
allow_randomize=allow_randomize,
persist_changes=persist_config, persist_changes=persist_config,
fallback_languages=global_random_languages, fallback_languages=global_random_languages,
) )
@@ -618,7 +604,6 @@ def _apply_prepare_form(
str, str,
bool, bool,
bool, bool,
bool,
]: ]:
raw_chunk_level = (form.get("chunk_level") or pending.chunk_level or "paragraph").strip().lower() raw_chunk_level = (form.get("chunk_level") or pending.chunk_level or "paragraph").strip().lower()
if raw_chunk_level not in _CHUNK_LEVEL_VALUES: if raw_chunk_level not in _CHUNK_LEVEL_VALUES:
@@ -665,11 +650,7 @@ def _apply_prepare_form(
pending.speakers["narrator"] = existing_narrator pending.speakers["narrator"] = existing_narrator
selected_config = (form.get("applied_speaker_config") or "").strip() 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"}
apply_config_requested = (
str(form.get("apply_speaker_config", "")).strip() in {"1", "true", "on"}
or randomize_requested
)
persist_config_requested = str(form.get("save_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 pending.applied_speaker_config = selected_config or None
@@ -694,9 +675,6 @@ def _apply_prepare_form(
payload.pop("voice", None) payload.pop("voice", None)
payload.pop("resolved_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" lang_key = f"speaker-{speaker_id}-languages"
languages: List[str] = [] languages: List[str] = []
getter = getattr(form, "getlist", None) getter = getattr(form, "getlist", None)
@@ -782,7 +760,6 @@ def _apply_prepare_form(
errors, errors,
selected_total, selected_total,
selected_config, selected_config,
randomize_requested,
apply_config_requested, apply_config_requested,
persist_config_requested, persist_config_requested,
) )
@@ -833,6 +810,9 @@ _SUPPLEMENT_TEXT_KEYWORDS: List[tuple[str, float]] = [
("also by", 0.9), ("also by", 0.9),
("glossary", 0.8), ("glossary", 0.8),
("index", 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() custom_formula_raw = request.form.get("voice_formula", "").strip()
selected_speaker_config = (request.form.get("speaker_config") or "").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 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", ""}: if profile_selection in {"__standard", ""}:
profile_name = "" profile_name = ""
@@ -1907,7 +1879,6 @@ def enqueue_job() -> ResponseReturnValue:
run_analysis=initial_analysis, run_analysis=initial_analysis,
speaker_config=speaker_config_payload, speaker_config=speaker_config_payload,
apply_config=bool(speaker_config_payload), apply_config=bool(speaker_config_payload),
allow_randomize=config_randomize_default,
) )
pending = PendingJob( pending = PendingJob(
@@ -1983,7 +1954,6 @@ def analyze_pending_job(pending_id: str) -> ResponseReturnValue:
errors, errors,
selected_total, selected_total,
selected_config, selected_config,
randomize_requested,
apply_config_requested, apply_config_requested,
persist_config_requested, persist_config_requested,
) = _apply_prepare_form(pending, request.form) ) = _apply_prepare_form(pending, request.form)
@@ -2036,7 +2006,6 @@ def analyze_pending_job(pending_id: str) -> ResponseReturnValue:
run_analysis=True, run_analysis=True,
speaker_config=speaker_config_payload, speaker_config=speaker_config_payload,
apply_config=apply_config_requested or bool(speaker_config_payload), apply_config=apply_config_requested or bool(speaker_config_payload),
allow_randomize=randomize_requested,
persist_config=persist_config_requested, persist_config=persist_config_requested,
) )
@@ -2077,7 +2046,6 @@ def finalize_job(pending_id: str) -> ResponseReturnValue:
errors, errors,
selected_total, selected_total,
selected_config, selected_config,
randomize_requested,
apply_config_requested, apply_config_requested,
persist_config_requested, persist_config_requested,
) = _apply_prepare_form(pending, request.form) ) = _apply_prepare_form(pending, request.form)
@@ -2126,7 +2094,6 @@ def finalize_job(pending_id: str) -> ResponseReturnValue:
run_analysis=analysis_active, run_analysis=analysis_active,
speaker_config=speaker_config_payload, speaker_config=speaker_config_payload,
apply_config=apply_config_requested or bool(speaker_config_payload), apply_config=apply_config_requested or bool(speaker_config_payload),
allow_randomize=randomize_requested,
persist_config=persist_config_requested, persist_config=persist_config_requested,
) )
pending.chunks = processed_chunks pending.chunks = processed_chunks
+140 -101
View File
@@ -18,6 +18,63 @@ document.addEventListener("DOMContentLoaded", () => {
const languageMap = parseJSONScript("voice-language-map") || {}; const languageMap = parseJSONScript("voice-language-map") || {};
const voiceCatalogMap = new Map(voiceCatalog.map((voice) => [voice.id, voice])); 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 chapterRows = Array.from(form.querySelectorAll("[data-role=chapter-row]"));
const updateRowState = (row) => { const updateRowState = (row) => {
@@ -87,99 +144,91 @@ document.addEventListener("DOMContentLoaded", () => {
const updatePreviewVoice = (select) => { const updatePreviewVoice = (select) => {
const container = select.closest(".speaker-list__item"); const container = select.closest(".speaker-list__item");
if (!container) return; if (!container) return;
const previewButton = container.querySelector('[data-role="speaker-preview"]'); const previewButtons = container.querySelectorAll('[data-role="speaker-preview"]');
if (!previewButton) return; 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();
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 handleRandomizeToggle = (checkbox) => { const formulaInput = container.querySelector('[data-role="speaker-formula"]');
const container = checkbox.closest(".speaker-list__item"); const mixContainer = container.querySelector('[data-role="speaker-mix"]');
if (!container) return; const mixLabel = container.querySelector('[data-role="speaker-mix-label"]');
const select = container.querySelector('[data-role="speaker-voice"]');
if (!select) return; const formulaValue = formulaInput?.value?.trim() || "";
if (checkbox.checked) { updateCustomMixOption(select, formulaValue);
if (!select.dataset.prevManual) {
select.dataset.prevManual = select.value; const defaultVoice = select.dataset.defaultVoice || "";
} let assignedVoice = select.value || defaultVoice;
const formulaInput = container.querySelector('[data-role="speaker-formula"]'); if (select.value === "__custom_mix" || formulaValue) {
const mixContainer = container.querySelector('[data-role="speaker-mix"]'); assignedVoice = formulaValue || defaultVoice;
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;
} }
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"]')); const voiceSelects = Array.from(form.querySelectorAll('[data-role="speaker-voice"]'));
voiceSelects.forEach((select) => { voiceSelects.forEach((select) => {
ensureCustomMixOption(select);
select.addEventListener("change", (event) => { select.addEventListener("change", (event) => {
const target = event.target; const target = event.target;
const container = target.closest(".speaker-list__item"); const container = target.closest(".speaker-list__item");
if (container && !target.dataset.suppressRandomize) { if (!container) return;
const randomToggle = container.querySelector('[data-role="randomize-toggle"]'); const formulaInput = container.querySelector('[data-role="speaker-formula"]');
if (randomToggle && randomToggle.checked && target.value) { const mixContainer = container.querySelector('[data-role="speaker-mix"]');
randomToggle.checked = false; const mixLabel = container.querySelector('[data-role="speaker-mix-label"]');
handleRandomizeToggle(randomToggle);
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"]'); if (!target.dataset.suppressFormulaClear) {
const mixContainer = container.querySelector('[data-role="speaker-mix"]');
if (formulaInput) { if (formulaInput) {
formulaInput.value = ""; formulaInput.value = "";
} }
if (mixLabel) {
mixLabel.textContent = "";
}
if (mixContainer) { if (mixContainer) {
mixContainer.hidden = true; mixContainer.hidden = true;
} }
updateCustomMixOption(target, "");
} }
if (!target.dataset.suppressRandomize) {
target.dataset.prevManual = target.value || ""; target.dataset.prevManual = target.value || "";
}
updatePreviewVoice(target); updatePreviewVoice(target);
delete target.dataset.suppressFormulaClear; delete target.dataset.suppressFormulaClear;
}); });
updatePreviewVoice(select); 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 finalizeAction = form.getAttribute("action");
const analyzeUrl = form.dataset.analyzeUrl || ""; const analyzeUrl = form.dataset.analyzeUrl || "";
const activeStepInput = form.querySelector('[data-role="active-step-input"]'); 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 formulaInput = speakerItem.querySelector('[data-role="speaker-formula"]');
const mixLabel = speakerItem.querySelector('[data-role="speaker-mix-label"]'); const mixLabel = speakerItem.querySelector('[data-role="speaker-mix-label"]');
const mixContainer = speakerItem.querySelector('[data-role="speaker-mix"]'); 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) { if (formulaInput) {
formulaInput.value = formula || ""; formulaInput.value = formula || "";
} }
@@ -442,26 +486,29 @@ document.addEventListener("DOMContentLoaded", () => {
if (mixContainer) { if (mixContainer) {
mixContainer.hidden = !formula; mixContainer.hidden = !formula;
} }
if (select) {
select.disabled = false; if (!select) {
select.dataset.suppressRandomize = "1"; 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"; select.dataset.suppressFormulaClear = "1";
if (formula) { updateCustomMixOption(select, formula);
select.value = ""; select.value = "__custom_mix";
} else if (!select.value && select.dataset.prevManual) { } else {
select.value = select.dataset.prevManual; const fallback = select.dataset.prevManual || select.dataset.defaultVoice || "";
} select.dataset.suppressFormulaClear = "1";
select.dispatchEvent(new Event("change", { bubbles: true })); updateCustomMixOption(select, "");
delete select.dataset.suppressRandomize; select.value = fallback;
}
if (previewButton) {
if (formula) {
previewButton.dataset.voice = formula;
} else {
const defaultVoice = select?.dataset.defaultVoice || previewButton.dataset.voice || "";
previewButton.dataset.voice = select?.value || defaultVoice;
}
} }
updatePreviewVoice(select);
delete select.dataset.suppressFormulaClear;
}; };
const hideGenderMenus = () => { const hideGenderMenus = () => {
@@ -999,16 +1046,8 @@ document.addEventListener("DOMContentLoaded", () => {
if (!container) return; if (!container) return;
const select = container.querySelector('[data-role="speaker-voice"]'); const select = container.querySelector('[data-role="speaker-voice"]');
if (!select) return; 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.value = chip.dataset.voice || "";
select.dispatchEvent(new Event("change", { bubbles: true })); select.dispatchEvent(new Event("change", { bubbles: true }));
delete select.dataset.suppressRandomize;
select.dataset.prevManual = select.value || ""; select.dataset.prevManual = select.value || "";
}); });
+68 -9
View File
@@ -1231,6 +1231,62 @@ body {
gap: 0.5rem; gap: 0.5rem;
font-size: 0.8rem; font-size: 0.8rem;
margin-bottom: 0.3rem; 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 { .speaker-list__controls {
@@ -1289,21 +1345,21 @@ body {
.prepare-form { .prepare-form {
display: grid; display: grid;
gap: 1.5rem; gap: 2rem;
} }
.chapter-grid { .chapter-grid {
display: grid; display: grid;
gap: 1rem; gap: 1.25rem;
} }
.chapter-card { .chapter-card {
border: 1px solid rgba(148, 163, 184, 0.18); border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 20px; border-radius: 22px;
padding: 1rem 1.25rem; padding: 1.25rem 1.5rem;
background: rgba(15, 23, 42, 0.32); background: rgba(15, 23, 42, 0.32);
display: grid; display: grid;
gap: 0.85rem; gap: 1.1rem;
} }
.chapter-card[data-disabled="true"] { .chapter-card[data-disabled="true"] {
@@ -1314,7 +1370,7 @@ body {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: flex-start; align-items: flex-start;
gap: 1rem; gap: 1.25rem;
} }
.chapter-card__title { .chapter-card__title {
@@ -1517,17 +1573,20 @@ body {
.prepare-actions { .prepare-actions {
display: flex; display: flex;
justify-content: space-between; justify-content: flex-end;
align-items: center; align-items: center;
gap: 1rem; gap: 1rem;
padding-top: 1.25rem;
} }
.prepare-actions .button { .prepare-actions .button {
min-width: 190px; min-width: 200px;
padding-left: 1.75rem;
padding-right: 1.75rem;
} }
.prepare-actions .button--ghost { .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 { .toggle-pill input:checked + span {
+32 -37
View File
@@ -43,16 +43,6 @@
<div class="alert alert--info">{{ notice }}</div> <div class="alert alert--info">{{ notice }}</div>
{% endif %} {% 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"> <div class="prepare-steps" data-role="prepare-step-panels">
<section class="prepare-step" data-step-panel="chapters"> <section class="prepare-step" data-step-panel="chapters">
<header class="prepare-step__header"> <header class="prepare-step__header">
@@ -198,16 +188,6 @@
{% if not speaker_configs %}disabled{% endif %}> {% if not speaker_configs %}disabled{% endif %}>
Apply preset Apply preset
</button> </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"> <label class="toggle-pill">
<input type="checkbox" name="save_speaker_config" value="1"> <input type="checkbox" name="save_speaker_config" value="1">
<span>Save roster updates back to preset</span> <span>Save roster updates back to preset</span>
@@ -274,9 +254,6 @@
{% if speaker.get('analysis_count') %} {% if speaker.get('analysis_count') %}
<span class="badge badge--muted">{{ speaker.analysis_count }} lines · {{ speaker.analysis_confidence|default('low')|title }} confidence</span> <span class="badge badge--muted">{{ speaker.analysis_count }} lines · {{ speaker.analysis_confidence|default('low')|title }} confidence</span>
{% endif %} {% endif %}
{% if settings.speaker_random_languages %}
<span class="badge badge--muted">Randomizer: {{ settings.speaker_random_languages | map('upper') | join(', ') }}</span>
{% endif %}
</div> </div>
<label class="speaker-list__field" for="speaker-{{ speaker_id }}-pronunciation"> <label class="speaker-list__field" for="speaker-{{ speaker_id }}-pronunciation">
<span>Pronunciation</span> <span>Pronunciation</span>
@@ -293,9 +270,11 @@
<select id="speaker-{{ speaker_id }}-voice" <select id="speaker-{{ speaker_id }}-voice"
name="speaker-{{ speaker_id }}-voice" name="speaker-{{ speaker_id }}-voice"
data-role="speaker-voice" data-role="speaker-voice"
data-default-voice="{{ pending.voice }}" data-default-voice="{{ pending.voice }}">
{% if speaker.randomize %}disabled{% endif %}>
<option value="" {% if not selected_voice %}selected{% endif %}>Use narrator voice ({{ pending.voice }})</option> <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 %} {% if speaker.recommended_voices %}
<optgroup label="Recommended"> <optgroup label="Recommended">
{% for voice_id in speaker.recommended_voices[:6] %} {% for voice_id in speaker.recommended_voices[:6] %}
@@ -333,21 +312,38 @@
data-speaker-id="{{ speaker_id }}"> data-speaker-id="{{ speaker_id }}">
Generate voice Generate voice
</button> </button>
</div> <button type="button"
<div class="speaker-list__inline"> class="button button--ghost button--small"
<label class="toggle-pill"> data-role="speaker-preview"
<input type="checkbox" data-preview-kind="generated"
name="speaker-{{ speaker_id }}-randomize" data-speaker-id="{{ speaker_id }}"
value="1" data-preview-text="{{ preview_text|e }}"
data-role="randomize-toggle" data-language="{{ pending.language }}"
{% if speaker.randomize %}checked{% endif %}> data-voice="{{ speaker.voice_formula or selected_voice or pending.voice }}"
<span>Randomize compatible voice on analyze</span> data-speed="{{ '%.2f'|format(pending.speed) }}"
</label> data-use-gpu="{{ 'true' if pending.use_gpu else 'false' }}"
{% if not speaker.voice_formula %}hidden{% endif %}>
Preview generated
</button>
</div> </div>
<div class="speaker-list__mix" data-role="speaker-mix" {% if not speaker.voice_formula %}hidden{% endif %}> <div class="speaker-list__mix" data-role="speaker-mix" {% if not speaker.voice_formula %}hidden{% endif %}>
<span class="tag">Custom mix</span> <span class="tag">Custom mix</span>
<span data-role="speaker-mix-label">{{ speaker.voice_formula or '' }}</span> <span data-role="speaker-mix-label">{{ speaker.voice_formula or '' }}</span>
<button type="button" class="button button--ghost button--small" data-role="clear-mix">Clear</button> <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> </div>
<input type="hidden" name="speaker-{{ speaker_id }}-formula" value="{{ speaker.voice_formula or '' }}" data-role="speaker-formula"> <input type="hidden" name="speaker-{{ speaker_id }}-formula" value="{{ speaker.voice_formula or '' }}" data-role="speaker-formula">
</div> </div>
@@ -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="f">Female</button>
<button type="button" class="button button--ghost button--small" data-role="voice-modal-gender" data-value="m">Male</button> <button type="button" class="button button--ghost button--small" data-role="voice-modal-gender" data-value="m">Male</button>
</div> </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> <button type="button" class="button button--ghost" data-role="voice-modal-clear">Clear selection</button>
</aside> </aside>
<section class="voice-browser__catalog" aria-label="Voice catalog"> <section class="voice-browser__catalog" aria-label="Voice catalog">
+3 -8
View File
@@ -2,7 +2,6 @@
{% macro speaker_row(row_key, speaker, options) %} {% macro speaker_row(row_key, speaker, options) %}
{% set gender = (speaker.gender or 'unknown') %} {% set gender = (speaker.gender or 'unknown') %}
{% set randomize = speaker.randomize %}
<div class="speaker-config-row" data-role="speaker-row" data-row-id="{{ row_key }}"> <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_rows" value="{{ row_key }}">
<input type="hidden" name="speaker-{{ row_key }}-id" value="{{ speaker.id or row_key }}"> <input type="hidden" name="speaker-{{ row_key }}-id" value="{{ speaker.id or row_key }}">
@@ -30,10 +29,6 @@
</label> </label>
</div> </div>
<div class="speaker-config-row__footer"> <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"> <button type="button" class="button button--ghost button--small" data-action="remove-speaker">
Remove Remove
</button> </button>
@@ -60,7 +55,7 @@
</span> </span>
</div> </div>
<div class="card__title">Speaker presets</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 %} {% if message %}
<div class="alert alert--success">{{ message }}</div> <div class="alert alert--success">{{ message }}</div>
@@ -125,7 +120,7 @@
<h3>Speakers</h3> <h3>Speakers</h3>
<button type="button" class="button button--ghost button--small" data-action="add-speaker">Add speaker</button> <button type="button" class="button button--ghost button--small" data-action="add-speaker">Add speaker</button>
</div> </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"> <div class="speaker-config-rows__list" data-role="speaker-rows">
{% set speakers_map = editing.speakers or {} %} {% set speakers_map = editing.speakers or {} %}
{% if speakers_map %} {% if speakers_map %}
@@ -147,7 +142,7 @@
</div> </div>
<template id="speaker-row-template"> <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> </template>
</section> </section>
{% endblock %} {% endblock %}