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
+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
+140 -101
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 formulaInput = container.querySelector('[data-role="speaker-formula"]');
const mixContainer = container.querySelector('[data-role="speaker-mix"]');
const mixLabel = container.querySelector('[data-role="speaker-mix-label"]');
const formulaValue = formulaInput?.value?.trim();
if (formulaValue) {
previewButton.dataset.voice = formulaValue;
if (mixContainer) {
mixContainer.hidden = false;
}
if (mixLabel) {
mixLabel.textContent = formulaValue;
}
return;
}
if (mixContainer) {
mixContainer.hidden = true;
}
const defaultVoice = select.dataset.defaultVoice || previewButton.dataset.voice || "";
const currentVoice = select.disabled ? defaultVoice : (select.value || defaultVoice);
previewButton.dataset.voice = currentVoice || defaultVoice;
};
const previewButtons = container.querySelectorAll('[data-role="speaker-preview"]');
if (!previewButtons.length) return;
const handleRandomizeToggle = (checkbox) => {
const container = checkbox.closest(".speaker-list__item");
if (!container) return;
const select = container.querySelector('[data-role="speaker-voice"]');
if (!select) return;
if (checkbox.checked) {
if (!select.dataset.prevManual) {
select.dataset.prevManual = select.value;
}
const formulaInput = container.querySelector('[data-role="speaker-formula"]');
const mixContainer = container.querySelector('[data-role="speaker-mix"]');
if (formulaInput) {
formulaInput.value = "";
}
if (mixContainer) {
mixContainer.hidden = true;
}
select.dataset.suppressRandomize = "1";
select.disabled = true;
select.value = "";
select.dispatchEvent(new Event("change", { bubbles: true }));
delete select.dataset.suppressRandomize;
} else {
const previous = select.dataset.prevManual || "";
select.disabled = false;
select.dataset.suppressRandomize = "1";
select.value = previous;
select.dispatchEvent(new Event("change", { bubbles: true }));
delete select.dataset.suppressRandomize;
const formulaInput = container.querySelector('[data-role="speaker-formula"]');
const mixContainer = container.querySelector('[data-role="speaker-mix"]');
const mixLabel = container.querySelector('[data-role="speaker-mix-label"]');
const formulaValue = formulaInput?.value?.trim() || "";
updateCustomMixOption(select, formulaValue);
const defaultVoice = select.dataset.defaultVoice || "";
let assignedVoice = select.value || defaultVoice;
if (select.value === "__custom_mix" || formulaValue) {
assignedVoice = formulaValue || defaultVoice;
}
if (formulaValue) {
if (mixContainer) mixContainer.hidden = false;
if (mixLabel) mixLabel.textContent = formulaValue;
} else {
if (mixContainer) mixContainer.hidden = true;
if (mixLabel) mixLabel.textContent = "";
if (assignedVoice === "__custom_mix" || !assignedVoice) {
assignedVoice = defaultVoice;
}
}
previewButtons.forEach((button) => {
const kind = button.dataset.previewKind || "";
if (kind === "generated") {
button.hidden = !formulaValue;
button.dataset.voice = assignedVoice;
return;
}
const context = button.dataset.previewContext || "";
if (context === "mix") {
button.dataset.voice = formulaValue || assignedVoice;
return;
}
button.dataset.voice = assignedVoice || defaultVoice || button.dataset.voice || "";
});
};
const voiceSelects = Array.from(form.querySelectorAll('[data-role="speaker-voice"]'));
voiceSelects.forEach((select) => {
ensureCustomMixOption(select);
select.addEventListener("change", (event) => {
const target = event.target;
const container = target.closest(".speaker-list__item");
if (container && !target.dataset.suppressRandomize) {
const randomToggle = container.querySelector('[data-role="randomize-toggle"]');
if (randomToggle && randomToggle.checked && target.value) {
randomToggle.checked = false;
handleRandomizeToggle(randomToggle);
if (!container) return;
const formulaInput = container.querySelector('[data-role="speaker-formula"]');
const mixContainer = container.querySelector('[data-role="speaker-mix"]');
const mixLabel = container.querySelector('[data-role="speaker-mix-label"]');
if (target.value === "__custom_mix") {
if (!formulaInput?.value?.trim()) {
const previous = target.dataset.prevManual || "";
target.value = previous;
}
updatePreviewVoice(target);
return;
}
if (container && !target.dataset.suppressFormulaClear) {
const formulaInput = container.querySelector('[data-role="speaker-formula"]');
const mixContainer = container.querySelector('[data-role="speaker-mix"]');
if (!target.dataset.suppressFormulaClear) {
if (formulaInput) {
formulaInput.value = "";
}
if (mixLabel) {
mixLabel.textContent = "";
}
if (mixContainer) {
mixContainer.hidden = true;
}
updateCustomMixOption(target, "");
}
if (!target.dataset.suppressRandomize) {
target.dataset.prevManual = target.value || "";
}
target.dataset.prevManual = target.value || "";
updatePreviewVoice(target);
delete target.dataset.suppressFormulaClear;
});
updatePreviewVoice(select);
});
const randomizeToggles = Array.from(form.querySelectorAll('[data-role="randomize-toggle"]'));
randomizeToggles.forEach((checkbox) => {
handleRandomizeToggle(checkbox);
checkbox.addEventListener("change", () => handleRandomizeToggle(checkbox));
});
const finalizeAction = form.getAttribute("action");
const analyzeUrl = form.dataset.analyzeUrl || "";
const activeStepInput = form.querySelector('[data-role="active-step-input"]');
@@ -427,12 +476,7 @@ document.addEventListener("DOMContentLoaded", () => {
const formulaInput = speakerItem.querySelector('[data-role="speaker-formula"]');
const mixLabel = speakerItem.querySelector('[data-role="speaker-mix-label"]');
const mixContainer = speakerItem.querySelector('[data-role="speaker-mix"]');
const previewButton = speakerItem.querySelector('[data-role="speaker-preview"]');
const randomToggle = speakerItem.querySelector('[data-role="randomize-toggle"]');
if (randomToggle && randomToggle.checked) {
randomToggle.checked = false;
handleRandomizeToggle(randomToggle);
}
if (formulaInput) {
formulaInput.value = formula || "";
}
@@ -442,26 +486,29 @@ document.addEventListener("DOMContentLoaded", () => {
if (mixContainer) {
mixContainer.hidden = !formula;
}
if (select) {
select.disabled = false;
select.dataset.suppressRandomize = "1";
if (!select) {
return;
}
ensureCustomMixOption(select);
if (formula) {
if (select.value !== "__custom_mix") {
select.dataset.prevManual = select.value || select.dataset.prevManual || select.dataset.defaultVoice || "";
}
select.dataset.suppressFormulaClear = "1";
if (formula) {
select.value = "";
} else if (!select.value && select.dataset.prevManual) {
select.value = select.dataset.prevManual;
}
select.dispatchEvent(new Event("change", { bubbles: true }));
delete select.dataset.suppressRandomize;
}
if (previewButton) {
if (formula) {
previewButton.dataset.voice = formula;
} else {
const defaultVoice = select?.dataset.defaultVoice || previewButton.dataset.voice || "";
previewButton.dataset.voice = select?.value || defaultVoice;
}
updateCustomMixOption(select, formula);
select.value = "__custom_mix";
} else {
const fallback = select.dataset.prevManual || select.dataset.defaultVoice || "";
select.dataset.suppressFormulaClear = "1";
updateCustomMixOption(select, "");
select.value = fallback;
}
updatePreviewVoice(select);
delete select.dataset.suppressFormulaClear;
};
const hideGenderMenus = () => {
@@ -999,16 +1046,8 @@ document.addEventListener("DOMContentLoaded", () => {
if (!container) return;
const select = container.querySelector('[data-role="speaker-voice"]');
if (!select) return;
const randomToggle = container.querySelector('[data-role="randomize-toggle"]');
if (randomToggle && randomToggle.checked) {
randomToggle.checked = false;
handleRandomizeToggle(randomToggle);
}
select.disabled = false;
select.dataset.suppressRandomize = "1";
select.value = chip.dataset.voice || "";
select.dispatchEvent(new Event("change", { bubbles: true }));
delete select.dataset.suppressRandomize;
select.dataset.prevManual = select.value || "";
});
+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 {
+32 -37
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,21 +312,38 @@
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>
<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>
<input type="hidden" name="speaker-{{ speaker_id }}-formula" value="{{ speaker.voice_formula or '' }}" data-role="speaker-formula">
</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="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 %}