mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 21:50:28 +02:00
feat: Refactor speaker configuration and UI components, enhance custom mix functionality, and improve styling for better usability
This commit is contained in:
@@ -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
@@ -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
@@ -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 || "";
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user