Refactor templates for audiobook preparation and settings

- Updated navigation in base.html to include a link to the queue section.
- Modified index.html to change step labels and added a jobs panel for real-time updates.
- Enhanced prepare_job.html with a wizard-style step navigation and improved speaker configuration options.
- Added new fields for speaker analysis settings and chapter options in prepare_job.html.
- Introduced a voice selection modal for better user experience in speaker selection.
- Updated settings.html to allow selection of randomizer languages for speakers.
- Cleaned up speakers.html by removing unnecessary language selection fields and ensuring consistency in speaker configuration.
This commit is contained in:
JB
2025-10-08 12:50:50 -07:00
parent d01887f31b
commit 881717c4cb
8 changed files with 953 additions and 339 deletions
+22 -3
View File
@@ -359,9 +359,28 @@ def _reassign(assignments: Dict[str, str], old: str, new: str) -> None:
def _count_gender_votes(text: str) -> Tuple[int, int]:
if not text:
return 0, 0
male = len(_MALE_PRONOUN_PATTERN.findall(text))
female = len(_FEMALE_PRONOUN_PATTERN.findall(text))
return male, female
male_votes = 0.0
for token in _MALE_PRONOUN_PATTERN.findall(text):
lowered = token.lower()
if lowered in {"he", "himself"}:
male_votes += 1.0
elif lowered == "his":
male_votes += 0.75
else: # him
male_votes += 0.6
female_votes = 0.0
for token in _FEMALE_PRONOUN_PATTERN.findall(text):
lowered = token.lower()
if lowered in {"she", "herself"}:
female_votes += 1.0
elif lowered == "hers":
female_votes += 0.75
else: # her
female_votes += 0.4
return int(round(male_votes)), int(round(female_votes))
def _derive_gender(male_votes: int, female_votes: int, current: str) -> str:
+74 -22
View File
@@ -189,26 +189,44 @@ def _apply_speaker_config_to_roster(
*,
allow_randomize: bool = False,
persist_changes: bool = False,
fallback_languages: Optional[Iterable[str]] = None,
) -> Tuple[Dict[str, Any], List[str], Optional[Dict[str, Any]]]:
if not isinstance(roster, Mapping):
return {}, [], None
effective_languages = [code for code in (fallback_languages or []) if isinstance(code, str) and code]
return {}, effective_languages, None
updated_roster: Dict[str, Any] = {key: dict(value) for key, value in roster.items() if isinstance(value, Mapping)}
if not config:
return updated_roster, [], None
effective_languages = [code for code in (fallback_languages or []) if isinstance(code, str) and code]
return updated_roster, effective_languages, None
speakers_map = config.get("speakers")
if not isinstance(speakers_map, Mapping):
return updated_roster, [], None
effective_languages = [code for code in (fallback_languages or []) if isinstance(code, str) and code]
return updated_roster, effective_languages, None
config_languages = config.get("languages")
if isinstance(config_languages, list):
allowed_languages = [code for code in config_languages if isinstance(code, str) and code]
else:
allowed_languages = []
if not allowed_languages and fallback_languages:
allowed_languages = [code for code in fallback_languages if isinstance(code, str) and code]
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()
if narrator_voice:
used_voices.add(narrator_voice)
config_changed = False
new_config_payload: Dict[str, Any] = {
"language": config.get("language", "a"),
@@ -236,11 +254,13 @@ def _apply_speaker_config_to_roster(
randomize_flag = bool(config_entry.get("randomize"))
chosen_voice = resolved_voice or voice_formula or voice_id or roster_entry.get("voice")
if allow_randomize and randomize_flag:
exclusion = used_voices | {voice_id, resolved_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=languages or allowed_languages,
allowed_languages=usable_languages,
fallback_voice=default_voice or roster_entry.get("voice"),
exclude=exclusion,
)
@@ -249,6 +269,7 @@ def _apply_speaker_config_to_roster(
voice_id = randomized
resolved_voice = randomized
config_changed = True
used_voices.add(randomized)
if chosen_voice:
roster_entry["resolved_voice"] = chosen_voice
@@ -260,7 +281,10 @@ def _apply_speaker_config_to_roster(
roster_entry["resolved_voice"] = voice_formula
if not voice_formula and not voice_profile and resolved_voice:
roster_entry["resolved_voice"] = resolved_voice
roster_entry["config_languages"] = languages or allowed_languages
roster_entry["config_languages"] = usable_languages or []
if chosen_voice:
used_voices.add(chosen_voice)
# persist updates back to config payload if required
if persist_changes:
@@ -273,7 +297,7 @@ def _apply_speaker_config_to_roster(
"voice_profile": voice_profile,
"voice_formula": voice_formula,
"resolved_voice": roster_entry.get("resolved_voice", resolved_voice or voice_id),
"languages": languages,
"languages": usable_languages,
"randomize": randomize_flag,
}
@@ -373,7 +397,7 @@ def _extract_speaker_config_form(form: Mapping[str, Any]) -> Tuple[str, Dict[str
name = (form.get("config_name") or "").strip()
language = str(form.get("config_language") or "a").strip() or "a"
allowed_languages = [code.lower() for code in _get_list("config_languages")]
allowed_languages = []
default_voice = (form.get("config_default_voice") or "").strip()
notes = (form.get("config_notes") or "").strip()
version = _coerce_int(form.get("config_version"), 1, minimum=1, maximum=9999)
@@ -391,7 +415,6 @@ def _extract_speaker_config_form(form: Mapping[str, Any]) -> Tuple[str, Dict[str
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"}
languages = [code.lower() for code in _get_list(prefix + "languages")]
speaker_id = (form.get(prefix + "id") or "").strip() or slugify_label(label)
speakers[speaker_id] = {
"id": speaker_id,
@@ -401,7 +424,7 @@ def _extract_speaker_config_form(form: Mapping[str, Any]) -> Tuple[str, Dict[str
"voice_profile": voice_profile,
"voice_formula": voice_formula,
"resolved_voice": voice_formula or voice,
"languages": languages,
"languages": [],
"randomize": randomize_flag,
}
@@ -443,6 +466,12 @@ def _prepare_speaker_metadata(
analysis_source = [dict(chunk) for chunk in (analysis_chunks or chunks)]
threshold_value = max(1, int(threshold))
analysis_enabled = speaker_mode == "multi" and run_analysis
settings_state = _load_settings()
global_random_languages = [
code
for code in settings_state.get("speaker_random_languages", [])
if isinstance(code, str) and code
]
if not analysis_enabled:
for chunk in chunk_list:
@@ -534,6 +563,7 @@ def _prepare_speaker_metadata(
speaker_config,
allow_randomize=allow_randomize,
persist_changes=persist_config,
fallback_languages=global_random_languages,
)
speakers_payload = analysis_payload.get("speakers")
if isinstance(speakers_payload, dict):
@@ -544,8 +574,18 @@ def _prepare_speaker_metadata(
value = roster_payload.get(key)
if value:
speaker_meta[key] = value
effective_languages: List[str] = []
if applied_languages:
analysis_payload["config_languages"] = applied_languages
effective_languages = applied_languages
elif isinstance(analysis_payload.get("config_languages"), list):
effective_languages = [
code for code in analysis_payload.get("config_languages", []) if isinstance(code, str) and code
]
elif global_random_languages:
effective_languages = list(global_random_languages)
if effective_languages:
analysis_payload["config_languages"] = effective_languages
speakers_payload = analysis_payload.get("speakers")
if isinstance(speakers_payload, dict):
for roster_id, roster_payload in roster.items():
@@ -554,11 +594,7 @@ def _prepare_speaker_metadata(
if pronunciation_value:
speakers_payload[roster_id]["pronunciation"] = pronunciation_value
fallback_languages = applied_languages or []
if not fallback_languages:
config_langs = analysis_payload.get("config_languages")
if isinstance(config_langs, list):
fallback_languages = [code for code in config_langs if isinstance(code, str)]
fallback_languages = effective_languages or []
_inject_recommended_voices(roster, fallback_languages=fallback_languages)
for chunk in chunk_list:
@@ -962,6 +998,7 @@ def _settings_defaults() -> Dict[str, Any]:
"generate_epub3": False,
"speaker_analysis_threshold": _DEFAULT_ANALYSIS_THRESHOLD,
"speaker_pronunciation_sentence": "This is {{name}} speaking.",
"speaker_random_languages": [],
}
@@ -1031,6 +1068,13 @@ def _normalize_setting_value(key: str, value: Any, defaults: Dict[str, Any]) ->
if isinstance(value, str) and value in _SPEAKER_MODE_VALUES:
return value
return defaults[key]
if key == "speaker_random_languages":
if isinstance(value, (list, tuple, set)):
return [code for code in value if isinstance(code, str) and code in LANGUAGE_DESCRIPTIONS]
if isinstance(value, str):
parts = [item.strip().lower() for item in value.split(",") if item.strip()]
return [code for code in parts if code in LANGUAGE_DESCRIPTIONS]
return defaults.get(key, [])
return value if value is not None else defaults.get(key)
@@ -1195,12 +1239,13 @@ def index() -> str:
"index.html",
options=_template_options(),
settings=_load_settings(),
jobs_panel=_render_jobs_panel(),
)
@web_bp.get("/queue")
def queue_page() -> str:
return render_template("queue.html", jobs_panel=_render_jobs_panel())
def queue_page() -> ResponseReturnValue:
return redirect(url_for("web.index", _anchor="queue"))
@web_bp.route("/settings", methods=["GET", "POST"])
@@ -1256,6 +1301,13 @@ def settings_page() -> ResponseReturnValue:
sentence_value = defaults["speaker_pronunciation_sentence"]
updated["speaker_pronunciation_sentence"] = sentence_value
random_languages = [
code.lower()
for code in form.getlist("speaker_random_languages")
if isinstance(code, str) and code.lower() in LANGUAGE_DESCRIPTIONS
]
updated["speaker_random_languages"] = random_languages
cfg = load_config() or {}
cfg.update(updated)
save_config(cfg)
@@ -2126,7 +2178,7 @@ def finalize_job(pending_id: str) -> ResponseReturnValue:
job.applied_speaker_config = config_name
job.speaker_voice_languages = job.speaker_voice_languages or list(pending.speaker_voice_languages)
return redirect(url_for("web.queue_page"))
return redirect(url_for("web.index", _anchor="queue"))
@web_bp.post("/jobs/prepare/<pending_id>/cancel")
@@ -2142,7 +2194,7 @@ def cancel_pending_job(pending_id: str) -> ResponseReturnValue:
pending.cover_image_path.unlink()
except OSError:
pass
return redirect(url_for("web.index"))
return redirect(url_for("web.index", _anchor="queue"))
def _render_jobs_panel() -> str:
@@ -2225,7 +2277,7 @@ def clear_finished_jobs() -> ResponseReturnValue:
_service().clear_finished()
if request.headers.get("HX-Request"):
return _render_jobs_panel()
return redirect(url_for("web.queue_page"))
return redirect(url_for("web.index", _anchor="queue"))
@web_bp.get("/jobs/<job_id>/download")
+451
View File
@@ -2,6 +2,22 @@ document.addEventListener("DOMContentLoaded", () => {
const form = document.querySelector(".prepare-form");
if (!form) return;
const parseJSONScript = (id) => {
const el = document.getElementById(id);
if (!el) return null;
try {
const content = el.textContent || "";
return content ? JSON.parse(content) : null;
} catch (error) {
console.warn(`Failed to parse JSON script for ${id}`, error);
return null;
}
};
const voiceCatalog = parseJSONScript("voice-catalog-data") || [];
const languageMap = parseJSONScript("voice-language-map") || {};
const voiceCatalogMap = new Map(voiceCatalog.map((voice) => [voice.id, voice]));
const chapterRows = Array.from(form.querySelectorAll("[data-role=chapter-row]"));
const updateRowState = (row) => {
@@ -123,6 +139,9 @@ document.addEventListener("DOMContentLoaded", () => {
handleRandomizeToggle(randomToggle);
}
}
if (!target.dataset.suppressRandomize) {
target.dataset.prevManual = target.value || "";
}
updatePreviewVoice(target);
});
updatePreviewVoice(select);
@@ -134,7 +153,435 @@ document.addEventListener("DOMContentLoaded", () => {
checkbox.addEventListener("change", () => handleRandomizeToggle(checkbox));
});
const wizard = document.querySelector('[data-role="prepare-wizard"]');
if (wizard) {
const stepOrder = ["chapters", "speakers"];
const indicatorOrder = ["setup", ...stepOrder];
const indicator = wizard.querySelector('[data-role="wizard-indicator"]');
const indicatorSteps = indicator ? Array.from(indicator.querySelectorAll('[data-role="wizard-step"]')) : [];
const navButtons = Array.from(wizard.querySelectorAll('[data-role="prepare-step-nav"] [data-step-target]'));
const nextButtons = Array.from(wizard.querySelectorAll('[data-role="step-next"]'));
const prevButtons = Array.from(wizard.querySelectorAll('[data-role="step-prev"]'));
const panels = new Map();
stepOrder.forEach((step) => {
const panel = wizard.querySelector(`[data-step-panel="${step}"]`);
if (panel) {
panels.set(step, panel);
panel.hidden = step !== "chapters";
panel.setAttribute("aria-hidden", step === "chapters" ? "false" : "true");
}
});
const unlockedSteps = new Set(["chapters"]);
let currentStep = "chapters";
const updateIndicator = (activeStep) => {
const activeIndex = indicatorOrder.indexOf(activeStep);
indicatorSteps.forEach((item) => {
const key = item.dataset.stepKey;
if (!key) return;
const index = indicatorOrder.indexOf(key);
item.classList.remove("is-active", "is-complete");
if (index < activeIndex) {
item.classList.add("is-complete");
} else if (index === activeIndex) {
item.classList.add("is-active");
}
});
};
const unlockStep = (step) => {
if (unlockedSteps.has(step)) {
return;
}
unlockedSteps.add(step);
navButtons.forEach((button) => {
if (button.dataset.stepTarget === step) {
button.disabled = false;
button.removeAttribute("aria-disabled");
button.dataset.state = "unlocked";
}
});
};
const setStep = (step) => {
if (!panels.has(step)) {
return;
}
currentStep = step;
wizard.dataset.activeStep = step;
panels.forEach((panel, key) => {
const isActive = key === step;
panel.hidden = !isActive;
panel.setAttribute("aria-hidden", isActive ? "false" : "true");
});
navButtons.forEach((button) => {
const target = button.dataset.stepTarget;
if (!target) return;
const isActive = target === step;
button.classList.toggle("is-active", isActive);
if (button.dataset.state === "locked" && !unlockedSteps.has(target)) {
button.disabled = true;
button.setAttribute("aria-disabled", "true");
} else {
button.disabled = false;
button.removeAttribute("aria-disabled");
}
});
updateIndicator(step);
};
navButtons.forEach((button) => {
button.addEventListener("click", () => {
if (button.disabled) {
return;
}
const target = button.dataset.stepTarget;
if (!target) return;
unlockStep(target);
setStep(target);
});
});
nextButtons.forEach((button) => {
button.addEventListener("click", () => {
const target = button.dataset.stepTarget || "speakers";
unlockStep(target);
setStep(target);
});
});
prevButtons.forEach((button) => {
button.addEventListener("click", () => {
const target = button.dataset.stepTarget || "chapters";
setStep(target);
});
});
setStep(currentStep);
}
const voiceModal = document.querySelector('[data-role="voice-modal"]');
let activeGenderFilter = "";
const modalState = {
speakerItem: null,
samples: [],
recommended: new Set(),
selectedVoice: "",
defaultVoice: "",
previewSettings: { language: "a", speed: "1", useGpu: "true" },
};
const resetModalState = () => {
modalState.speakerItem = null;
modalState.samples = [];
modalState.recommended = new Set();
modalState.selectedVoice = "";
modalState.defaultVoice = "";
modalState.previewSettings = { language: "a", speed: "1", useGpu: "true" };
};
const renderVoiceList = (elements) => {
if (!elements) return;
const { list, searchInput, languageSelect } = elements;
if (!list) return;
list.innerHTML = "";
const term = (searchInput?.value || "").trim().toLowerCase();
const languageFilter = languageSelect?.value || "";
const filtered = voiceCatalog.filter((voice) => {
if (languageFilter && voice.language !== languageFilter) return false;
if (activeGenderFilter && voice.gender_code !== activeGenderFilter) return false;
if (term) {
const haystacks = [voice.display_name, voice.id, voice.language_label, languageMap[voice.language]]
.filter(Boolean)
.map((value) => value.toLowerCase());
if (!haystacks.some((value) => value.includes(term))) {
return false;
}
}
return true;
}).sort((a, b) => {
const aRecommended = modalState.recommended.has(a.id) ? 0 : 1;
const bRecommended = modalState.recommended.has(b.id) ? 0 : 1;
if (aRecommended !== bRecommended) {
return aRecommended - bRecommended;
}
return a.display_name.localeCompare(b.display_name);
});
if (!filtered.length) {
const emptyItem = document.createElement("li");
emptyItem.className = "voice-browser__empty";
emptyItem.textContent = "No voices matched your filters.";
list.appendChild(emptyItem);
return;
}
filtered.forEach((voice) => {
const item = document.createElement("li");
const button = document.createElement("button");
button.type = "button";
button.className = "voice-browser__entry";
button.dataset.role = "voice-modal-item";
button.dataset.voiceId = voice.id;
if (modalState.selectedVoice === voice.id) {
button.setAttribute("aria-current", "true");
}
if (modalState.recommended.has(voice.id)) {
button.dataset.recommended = "true";
}
const nameSpan = document.createElement("span");
nameSpan.className = "voice-browser__entry-name";
nameSpan.textContent = voice.display_name;
const metaSpan = document.createElement("span");
metaSpan.className = "voice-browser__entry-meta";
metaSpan.textContent = `${voice.language_label} · ${voice.gender}`;
button.appendChild(nameSpan);
button.appendChild(metaSpan);
item.appendChild(button);
list.appendChild(item);
});
};
const renderSamples = (elements) => {
if (!elements) return;
const { container } = elements;
if (!container) return;
container.innerHTML = "";
if (!modalState.samples.length) {
const empty = document.createElement("p");
empty.className = "hint";
empty.textContent = "No sample paragraphs available yet.";
container.appendChild(empty);
return;
}
modalState.samples.forEach((text, index) => {
const sample = document.createElement("article");
sample.className = "voice-browser__sample";
sample.dataset.sampleIndex = String(index);
if (index === 0) {
sample.dataset.active = "true";
}
const paragraph = document.createElement("p");
paragraph.textContent = text;
const actions = document.createElement("div");
actions.className = "voice-browser__sample-actions";
const previewButton = document.createElement("button");
previewButton.type = "button";
previewButton.className = "button button--ghost button--small";
previewButton.dataset.role = "speaker-preview";
previewButton.dataset.previewText = text;
previewButton.dataset.language = modalState.previewSettings.language;
previewButton.dataset.speed = modalState.previewSettings.speed;
previewButton.dataset.useGpu = modalState.previewSettings.useGpu;
previewButton.dataset.voice = modalState.selectedVoice || modalState.defaultVoice || "";
previewButton.textContent = "Preview sample";
actions.appendChild(previewButton);
sample.appendChild(paragraph);
sample.appendChild(actions);
container.appendChild(sample);
});
};
const updateModalVoiceMeta = (elements) => {
if (!elements) return;
const { nameLabel, metaLabel } = elements;
if (!nameLabel || !metaLabel) return;
if (!modalState.selectedVoice) {
nameLabel.textContent = "Select a voice to preview";
metaLabel.textContent = "";
return;
}
const voice = voiceCatalogMap.get(modalState.selectedVoice);
if (!voice) {
nameLabel.textContent = modalState.selectedVoice;
metaLabel.textContent = "";
return;
}
nameLabel.textContent = voice.display_name;
metaLabel.textContent = `${voice.language_label} · ${voice.gender}`;
};
const refreshModal = (elements) => {
renderVoiceList(elements);
renderSamples(elements);
updateModalVoiceMeta(elements);
if (elements?.applyButton) {
elements.applyButton.disabled = !modalState.selectedVoice;
}
};
const openVoiceBrowser = (speakerItem, sampleIndex = 0) => {
if (!voiceModal) return;
modalState.speakerItem = speakerItem;
const select = speakerItem.querySelector('[data-role="speaker-voice"]');
const previewTrigger = speakerItem.querySelector('[data-role="speaker-preview"]');
modalState.defaultVoice = select?.dataset.defaultVoice || previewTrigger?.dataset.voice || "";
modalState.selectedVoice = select && !select.disabled ? (select.value || modalState.defaultVoice) : modalState.defaultVoice;
modalState.previewSettings = {
language: previewTrigger?.dataset.language || "a",
speed: previewTrigger?.dataset.speed || "1",
useGpu: previewTrigger?.dataset.useGpu || "true",
};
const samplesTemplate = speakerItem.querySelector('template[data-role="speaker-samples"]');
let samples = [];
if (samplesTemplate) {
try {
const raw = samplesTemplate.innerHTML || "[]";
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
samples = parsed.filter((value) => typeof value === "string" && value.trim().length);
}
} catch (error) {
console.warn("Unable to parse speaker samples", error);
}
}
if (previewTrigger?.dataset.previewText) {
samples.unshift(previewTrigger.dataset.previewText);
}
const uniqueSamples = Array.from(new Set(samples));
if (sampleIndex > 0 && sampleIndex < uniqueSamples.length) {
const [picked] = uniqueSamples.splice(sampleIndex, 1);
uniqueSamples.unshift(picked);
}
modalState.samples = uniqueSamples;
modalState.recommended = new Set(
Array.from(speakerItem.querySelectorAll('[data-role="recommended-voice"]')).map((btn) => btn.dataset.voice).filter(Boolean)
);
activeGenderFilter = "";
const elements = {
list: voiceModal.querySelector('[data-role="voice-modal-list"]'),
searchInput: voiceModal.querySelector('[data-role="voice-modal-search"]'),
languageSelect: voiceModal.querySelector('[data-role="voice-modal-language"]'),
genderButtons: Array.from(voiceModal.querySelectorAll('[data-role="voice-modal-gender"]')),
container: voiceModal.querySelector('[data-role="voice-modal-samples"]'),
applyButton: voiceModal.querySelector('[data-role="voice-modal-apply"]'),
nameLabel: voiceModal.querySelector('[data-role="voice-modal-selected-name"]'),
metaLabel: voiceModal.querySelector('[data-role="voice-modal-selected-meta"]'),
};
if (elements.searchInput) elements.searchInput.value = "";
if (elements.languageSelect) elements.languageSelect.value = "";
elements.genderButtons.forEach((button) => {
button.setAttribute("aria-pressed", button.dataset.value === "" ? "true" : "false");
});
refreshModal(elements);
voiceModal.hidden = false;
voiceModal.dataset.open = "true";
document.body.classList.add("modal-open");
if (elements.searchInput) {
setTimeout(() => elements.searchInput.focus({ preventScroll: true }), 0);
}
};
const closeVoiceBrowser = () => {
if (!voiceModal || voiceModal.hidden) return;
voiceModal.hidden = true;
voiceModal.removeAttribute("data-open");
document.body.classList.remove("modal-open");
resetModalState();
};
if (voiceModal) {
const elements = {
list: voiceModal.querySelector('[data-role="voice-modal-list"]'),
searchInput: voiceModal.querySelector('[data-role="voice-modal-search"]'),
languageSelect: voiceModal.querySelector('[data-role="voice-modal-language"]'),
genderButtons: Array.from(voiceModal.querySelectorAll('[data-role="voice-modal-gender"]')),
container: voiceModal.querySelector('[data-role="voice-modal-samples"]'),
applyButton: voiceModal.querySelector('[data-role="voice-modal-apply"]'),
nameLabel: voiceModal.querySelector('[data-role="voice-modal-selected-name"]'),
metaLabel: voiceModal.querySelector('[data-role="voice-modal-selected-meta"]'),
};
if (elements.searchInput) {
elements.searchInput.addEventListener("input", () => renderVoiceList(elements));
}
if (elements.languageSelect) {
elements.languageSelect.addEventListener("change", () => renderVoiceList(elements));
}
elements.genderButtons.forEach((button) => {
button.addEventListener("click", () => {
activeGenderFilter = button.dataset.value || "";
elements.genderButtons.forEach((btn) => btn.setAttribute("aria-pressed", btn === button ? "true" : "false"));
renderVoiceList(elements);
});
});
if (elements.list) {
elements.list.addEventListener("click", (event) => {
const target = event.target.closest('[data-role="voice-modal-item"]');
if (!target) return;
event.preventDefault();
modalState.selectedVoice = target.dataset.voiceId || "";
refreshModal(elements);
});
}
if (elements.applyButton) {
elements.applyButton.addEventListener("click", (event) => {
event.preventDefault();
if (!modalState.speakerItem || !modalState.selectedVoice) {
return;
}
const select = modalState.speakerItem.querySelector('[data-role="speaker-voice"]');
if (!select) return;
const randomToggle = modalState.speakerItem.querySelector('[data-role="randomize-toggle"]');
if (randomToggle && randomToggle.checked) {
randomToggle.checked = false;
handleRandomizeToggle(randomToggle);
}
select.disabled = false;
select.dataset.suppressRandomize = "1";
select.value = modalState.selectedVoice;
select.dispatchEvent(new Event("change", { bubbles: true }));
delete select.dataset.suppressRandomize;
select.dataset.prevManual = modalState.selectedVoice;
closeVoiceBrowser();
});
}
voiceModal.addEventListener("click", (event) => {
if (event.target.closest('[data-role="voice-modal-close"]')) {
event.preventDefault();
closeVoiceBrowser();
}
});
if (elements.container) {
elements.container.addEventListener("click", (event) => {
const sample = event.target.closest(".voice-browser__sample");
if (!sample) return;
elements.container.querySelectorAll(".voice-browser__sample").forEach((node) => node.removeAttribute("data-active"));
sample.dataset.active = "true";
});
}
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && !voiceModal.hidden) {
closeVoiceBrowser();
}
});
renderVoiceList(elements);
}
form.addEventListener("click", (event) => {
const modalTrigger = event.target.closest('[data-role="open-voice-browser"]');
if (modalTrigger) {
event.preventDefault();
const container = modalTrigger.closest(".speaker-list__item");
if (!container) return;
const sampleIndex = Number.parseInt(modalTrigger.dataset.sampleIndex || "0", 10);
openVoiceBrowser(container, Number.isNaN(sampleIndex) ? 0 : sampleIndex);
return;
}
const chip = event.target.closest('[data-role="recommended-voice"]');
if (!chip) return;
event.preventDefault();
@@ -147,7 +594,11 @@ document.addEventListener("DOMContentLoaded", () => {
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 || "";
});
});
+1 -1
View File
@@ -20,7 +20,7 @@
<a href="{{ url_for('web.index') }}" class="btn{% if endpoint == 'web.index' %} is-active{% endif %}">Dashboard</a>
<a href="{{ url_for('web.voice_profiles_page') }}" class="btn{% if endpoint == 'web.voice_profiles_page' %} is-active{% endif %}">Voice Mixer</a>
<a href="{{ url_for('web.speaker_configs_page') }}" class="btn{% if endpoint == 'web.speaker_configs_page' %} is-active{% endif %}">Speakers</a>
<a href="{{ url_for('web.queue_page') }}" class="btn{% if endpoint in ['web.queue_page', 'web.job_detail'] %} is-active{% endif %}">Queue</a>
<a href="{{ url_for('web.index', _anchor='queue') }}" class="btn{% if endpoint in ['web.queue_page', 'web.job_detail'] %} is-active{% endif %}">Queue</a>
<a href="{{ url_for('web.settings_page') }}" class="btn{% if endpoint == 'web.settings_page' %} is-active{% endif %}">Settings</a>
</nav>
</header>
+13 -3
View File
@@ -7,15 +7,15 @@
<div class="step-indicator" aria-label="Audiobook workflow">
<span class="step-indicator__item is-active">
<span class="step-indicator__index">1</span>
<span class="step-indicator__label">Upload</span>
<span class="step-indicator__label">Upload &amp; settings</span>
</span>
<span class="step-indicator__item">
<span class="step-indicator__index">2</span>
<span class="step-indicator__label">Speakers</span>
<span class="step-indicator__label">Chapters</span>
</span>
<span class="step-indicator__item">
<span class="step-indicator__index">3</span>
<span class="step-indicator__label">Queue</span>
<span class="step-indicator__label">Speakers</span>
</span>
</div>
<h1 class="card__title">Create a New Audiobook</h1>
@@ -132,6 +132,16 @@
</div>
</form>
</section>
<section class="card" id="queue">
<div id="jobs-panel"
hx-get="{{ url_for('web.jobs_partial') }}"
hx-trigger="load, every 3s"
hx-target="#jobs-panel"
hx-swap="innerHTML">
{{ jobs_panel|safe }}
</div>
</section>
{% endblock %}
{% block scripts %}
+280 -188
View File
@@ -3,19 +3,19 @@
{% block title %}Prepare · {{ pending.original_filename }}{% endblock %}
{% block content %}
<section class="card">
<div class="step-indicator" aria-label="Audiobook workflow">
<span class="step-indicator__item is-complete">
<section class="card" data-role="prepare-wizard">
<div class="step-indicator" aria-label="Audiobook workflow" data-role="wizard-indicator">
<span class="step-indicator__item is-complete" data-role="wizard-step" data-step-key="setup">
<span class="step-indicator__index">1</span>
<span class="step-indicator__label">Upload</span>
<span class="step-indicator__label">Upload &amp; settings</span>
</span>
<span class="step-indicator__item is-active">
<span class="step-indicator__item is-active" data-role="wizard-step" data-step-key="chapters">
<span class="step-indicator__index">2</span>
<span class="step-indicator__label">Speakers</span>
<span class="step-indicator__label">Chapters</span>
</span>
<span class="step-indicator__item">
<span class="step-indicator__item" data-role="wizard-step" data-step-key="speakers">
<span class="step-indicator__index">3</span>
<span class="step-indicator__label">Queue</span>
<span class="step-indicator__label">Speakers</span>
</span>
</div>
<div class="card__title">Prepare audiobook</div>
@@ -113,16 +113,6 @@
<p>Press <strong>Analyze speakers</strong> after selecting chapters to discover recurring voices.</p>
</div>
{% endif %}
{% if has_metadata %}
<div class="prepare-metadata">
<h2>Metadata</h2>
<ul>
{% for key, value in pending.metadata_tags.items() %}
<li><strong>{{ key|replace('_', ' ')|title }}:</strong> {{ value }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
{% endif %}
</div>
@@ -134,178 +124,17 @@
<div class="alert alert--info">{{ notice }}</div>
{% endif %}
<div class="prepare-speaker-config">
<div class="prepare-speaker-config__header">
<h2>Speaker configuration</h2>
<p class="hint">Reuse saved presets to keep character voices consistent between projects.</p>
</div>
<div class="prepare-speaker-config__grid">
<label class="field prepare-speaker-config__field" for="applied_speaker_config">
<span>Saved preset</span>
<select id="applied_speaker_config" name="applied_speaker_config">
<option value="">None</option>
{% for config in speaker_configs %}
<option value="{{ config.name }}" {% if pending.applied_speaker_config == config.name %}selected{% endif %}>
{{ config.name }} · {{ config.speakers|length }} speaker{% if config.speakers|length != 1 %}s{% endif %}
</option>
{% endfor %}
</select>
<p class="hint">Presets are managed on the <a href="{{ url_for('web.speaker_configs_page') }}">Speakers page</a>.</p>
</label>
<div class="prepare-speaker-config__actions">
<button type="submit"
class="button button--ghost"
name="apply_speaker_config"
value="1"
formaction="{{ url_for('web.analyze_pending_job', pending_id=pending.id) }}"
formmethod="post"
formnovalidate
{% 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>
</label>
</div>
</div>
<div class="prepare-step-nav" data-role="prepare-step-nav">
<button type="button" class="button button--ghost is-active" data-step-target="chapters">Step 2 · Chapters</button>
<button type="button" class="button button--ghost" data-step-target="speakers" disabled aria-disabled="true" data-state="locked">Step 3 · Speakers</button>
</div>
{% if roster %}
<div class="prepare-speakers">
<h2>Speaker settings</h2>
<p class="hint">Set pronunciations, lock specific voices, or limit the randomizer by language and gender.</p>
<ul class="speaker-list">
{% for speaker_id, speaker in roster.items() %}
{% set spoken_name = speaker.pronunciation or speaker.label %}
{% set preview_text = recommended_template | replace("{{name}}", spoken_name) %}
{% set selected_voice = speaker.resolved_voice or speaker.voice %}
{% set allowed_langs = speaker.config_languages or speaker.languages or [] %}
{% set seen = namespace(values=[]) %}
<li class="speaker-list__item" data-speaker-id="{{ speaker_id }}">
<div class="speaker-line speaker-list__header">
<span class="speaker-list__name">{{ speaker.label }}</span>
<button type="button"
class="icon-button speaker-list__preview"
data-role="speaker-preview"
data-speaker-id="{{ speaker_id }}"
data-preview-text="{{ preview_text|e }}"
data-language="{{ pending.language }}"
data-voice="{{ selected_voice or pending.voice }}"
data-speed="{{ '%.2f'|format(pending.speed) }}"
data-use-gpu="{{ 'true' if pending.use_gpu else 'false' }}"
aria-label="Preview pronunciation for {{ speaker.label }}"
title="Preview pronunciation">
🔊
</button>
</div>
<div class="speaker-list__meta">
{% if speaker.gender and speaker.gender != 'unknown' %}
<span class="badge badge--info">{{ speaker.gender|title }}</span>
{% else %}
<span class="badge badge--muted">Gender unknown</span>
{% endif %}
{% if speaker.get('analysis_count') %}
<span class="badge badge--muted">{{ speaker.analysis_count }} lines · {{ speaker.analysis_confidence|default('low')|title }} confidence</span>
{% endif %}
</div>
<label class="speaker-list__field" for="speaker-{{ speaker_id }}-pronunciation">
<span>Pronunciation</span>
<input type="text"
id="speaker-{{ speaker_id }}-pronunciation"
name="speaker-{{ speaker_id }}-pronunciation"
value="{{ speaker.pronunciation or '' }}"
placeholder="{{ speaker.label }}">
</label>
<div class="speaker-list__controls">
<label class="speaker-list__field" for="speaker-{{ speaker_id }}-voice">
<span>Assigned voice</span>
<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 %}>
<option value="" {% if not selected_voice %}selected{% endif %}>Use narrator voice ({{ pending.voice }})</option>
{% if speaker.recommended_voices %}
<optgroup label="Recommended">
{% for voice_id in speaker.recommended_voices[:6] %}
{% if voice_id not in seen.values %}
{% set voice_meta = options.voice_catalog_map.get(voice_id) or {} %}
<option value="{{ voice_id }}" {% if selected_voice == voice_id %}selected{% endif %}>
{{ voice_meta.display_name or voice_id }} · {{ voice_meta.language_label or voice_id[0]|upper }} · {{ voice_meta.gender or 'Unknown' }}
</option>
{% set _ = seen.values.append(voice_id) %}
{% endif %}
{% endfor %}
</optgroup>
{% endif %}
<optgroup label="All voices">
{% for voice in options.voice_catalog %}
{% if voice.id not in seen.values %}
<option value="{{ voice.id }}"
{% if selected_voice == voice.id %}selected{% endif %}>
{{ voice.display_name }} · {{ voice.language_label }} · {{ voice.gender }}
</option>
{% endif %}
{% endfor %}
</optgroup>
</select>
</label>
<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>
</div>
<label class="speaker-list__field" for="speaker-{{ speaker_id }}-languages">
<span>Allowed languages</span>
<select id="speaker-{{ speaker_id }}-languages"
name="speaker-{{ speaker_id }}-languages"
multiple
size="4"
data-role="speaker-languages"
{% if not allowed_langs %}data-placeholder="All languages"{% endif %}>
{% for code, label in options.languages.items() %}
<option value="{{ code }}" {% if code in allowed_langs %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
<p class="hint">When randomizing, voices will be selected from these languages.</p>
</label>
</div>
{% if speaker.recommended_voices %}
<div class="speaker-list__recommendations">
<span class="hint">Suggested:</span>
{% for voice_id in speaker.recommended_voices[:6] %}
{% set voice_meta = options.voice_catalog_map.get(voice_id) or {} %}
<button type="button"
class="chip"
data-role="recommended-voice"
data-voice="{{ voice_id }}"
title="{{ voice_meta.display_name or voice_id }} · {{ voice_meta.language_label or voice_id[0]|upper }} · {{ voice_meta.gender or 'Unknown' }}">
{{ voice_meta.display_name or voice_id }}
</button>
{% endfor %}
</div>
{% endif %}
</li>
{% endfor %}
</ul>
</div>
{% endif %}
<div class="prepare-steps" data-role="prepare-step-panels">
<section class="prepare-step" data-step-panel="chapters">
<header class="prepare-step__header">
<h2>Step 2 · Select chapters</h2>
<p class="hint">Choose which chapters to convert and tweak conversion options.</p>
</header>
<div class="chapter-grid">
{% for chapter in pending.chapters %}
@@ -402,7 +231,224 @@
</div>
</div>
<div class="prepare-step__actions">
<button type="button" class="button" data-role="step-next" data-step-target="speakers">Continue to speakers</button>
</div>
</section>
<section class="prepare-step" data-step-panel="speakers" hidden>
<header class="prepare-step__header">
<h2>Step 3 · Select speakers</h2>
<p class="hint">Assign voices, audition samples, and finalize your roster.</p>
</header>
<div class="prepare-speaker-config">
<div class="prepare-speaker-config__header">
<h2>Speaker configuration</h2>
<p class="hint">Reuse saved presets to keep character voices consistent between projects.</p>
</div>
<div class="prepare-speaker-config__grid">
<label class="field prepare-speaker-config__field" for="applied_speaker_config">
<span>Saved preset</span>
<select id="applied_speaker_config" name="applied_speaker_config">
<option value="">None</option>
{% for config in speaker_configs %}
<option value="{{ config.name }}" {% if pending.applied_speaker_config == config.name %}selected{% endif %}>
{{ config.name }} · {{ config.speakers|length }} speaker{% if config.speakers|length != 1 %}s{% endif %}
</option>
{% endfor %}
</select>
<p class="hint">Presets are managed on the <a href="{{ url_for('web.speaker_configs_page') }}">Speakers page</a>.</p>
</label>
<div class="prepare-speaker-config__actions">
<button type="submit"
class="button button--ghost"
name="apply_speaker_config"
value="1"
formaction="{{ url_for('web.analyze_pending_job', pending_id=pending.id) }}"
formmethod="post"
formnovalidate
{% 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>
</label>
</div>
</div>
</div>
{% if roster %}
<div class="prepare-speakers">
<h2>Speaker settings</h2>
<p class="hint">Set pronunciations, lock specific voices, and audition sample paragraphs to hear casting choices.</p>
<ul class="speaker-list">
{% for speaker_id, speaker in roster.items() %}
{% set spoken_name = speaker.pronunciation or speaker.label %}
{% set preview_text = recommended_template | replace("{{name}}", spoken_name) %}
{% set selected_voice = speaker.resolved_voice or speaker.voice %}
{% set seen = namespace(values=[]) %}
{% set sample_quotes = speaker.sample_quotes or [] %}
<li class="speaker-list__item" data-speaker-id="{{ speaker_id }}">
<div class="speaker-line speaker-list__header">
<span class="speaker-list__name">{{ speaker.label }}</span>
<button type="button"
class="icon-button speaker-list__preview"
data-role="speaker-preview"
data-speaker-id="{{ speaker_id }}"
data-preview-text="{{ preview_text|e }}"
data-language="{{ pending.language }}"
data-voice="{{ selected_voice or pending.voice }}"
data-speed="{{ '%.2f'|format(pending.speed) }}"
data-use-gpu="{{ 'true' if pending.use_gpu else 'false' }}"
aria-label="Preview pronunciation for {{ speaker.label }}"
title="Preview pronunciation">
🔊
</button>
</div>
<template data-role="speaker-samples">{{ sample_quotes | tojson }}</template>
<div class="speaker-list__meta">
{% if speaker.gender and speaker.gender != 'unknown' %}
<span class="badge badge--info">{{ speaker.gender|title }}</span>
{% else %}
<span class="badge badge--muted">Gender unknown</span>
{% endif %}
{% 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>
<input type="text"
id="speaker-{{ speaker_id }}-pronunciation"
name="speaker-{{ speaker_id }}-pronunciation"
value="{{ speaker.pronunciation or '' }}"
placeholder="{{ speaker.label }}">
</label>
<div class="speaker-list__controls">
<div class="speaker-list__selection">
<label class="speaker-list__field" for="speaker-{{ speaker_id }}-voice">
<span>Assigned voice</span>
<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 %}>
<option value="" {% if not selected_voice %}selected{% endif %}>Use narrator voice ({{ pending.voice }})</option>
{% if speaker.recommended_voices %}
<optgroup label="Recommended">
{% for voice_id in speaker.recommended_voices[:6] %}
{% if voice_id not in seen.values %}
{% set voice_meta = options.voice_catalog_map.get(voice_id) or {} %}
<option value="{{ voice_id }}" {% if selected_voice == voice_id %}selected{% endif %}>
{{ voice_meta.display_name or voice_id }} · {{ voice_meta.language_label or voice_id[0]|upper }} · {{ voice_meta.gender or 'Unknown' }}
</option>
{% set _ = seen.values.append(voice_id) %}
{% endif %}
{% endfor %}
</optgroup>
{% endif %}
<optgroup label="All voices">
{% for voice in options.voice_catalog %}
{% if voice.id not in seen.values %}
<option value="{{ voice.id }}"
{% if selected_voice == voice.id %}selected{% endif %}>
{{ voice.display_name }} · {{ voice.language_label }} · {{ voice.gender }}
</option>
{% endif %}
{% endfor %}
</optgroup>
</select>
</label>
<button type="button"
class="button button--ghost button--small"
data-role="open-voice-browser"
data-speaker-id="{{ speaker_id }}">
Browse voices
</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>
</div>
</div>
<details class="speaker-list__samples" {% if not sample_quotes %}data-state="empty"{% endif %}>
<summary>Sample paragraphs</summary>
{% if sample_quotes %}
{% for quote in sample_quotes %}
<article class="speaker-sample" data-sample-index="{{ loop.index0 }}">
<p>{{ quote }}</p>
<div class="speaker-sample__actions">
<button type="button"
class="button button--ghost button--small"
data-role="speaker-preview"
data-speaker-id="{{ speaker_id }}"
data-preview-text="{{ quote|e }}"
data-language="{{ pending.language }}"
data-voice="{{ selected_voice or pending.voice }}"
data-speed="{{ '%.2f'|format(pending.speed) }}"
data-use-gpu="{{ 'true' if pending.use_gpu else 'false' }}">
Preview with assigned voice
</button>
<button type="button"
class="button button--ghost button--small"
data-role="open-voice-browser"
data-speaker-id="{{ speaker_id }}"
data-sample-index="{{ loop.index0 }}">
Preview in voice browser
</button>
</div>
</article>
{% endfor %}
{% else %}
<p class="hint">No paragraphs captured yet. Analyze speakers to collect dialogue samples.</p>
{% endif %}
</details>
{% if speaker.recommended_voices %}
<div class="speaker-list__recommendations">
<span class="hint">Suggested:</span>
{% for voice_id in speaker.recommended_voices[:6] %}
{% set voice_meta = options.voice_catalog_map.get(voice_id) or {} %}
<button type="button"
class="chip"
data-role="recommended-voice"
data-voice="{{ voice_id }}"
title="{{ voice_meta.display_name or voice_id }} · {{ voice_meta.language_label or voice_id[0]|upper }} · {{ voice_meta.gender or 'Unknown' }}">
{{ voice_meta.display_name or voice_id }}
</button>
{% endfor %}
</div>
{% endif %}
</li>
{% endfor %}
</ul>
</div>
{% else %}
<p class="hint">No additional speakers detected yet. The narrator voice will be used for all dialogue.</p>
{% endif %}
<div class="prepare-actions">
<button type="button" class="button button--ghost" data-role="step-prev" data-step-target="chapters">Back to chapters</button>
<button type="submit"
class="button button--ghost"
data-role="analyze-button"
@@ -415,13 +461,59 @@
<button type="submit" class="button">Queue conversion</button>
<button type="submit" class="button button--ghost" form="cancel-form">Cancel</button>
</div>
</section>
</div>
</form>
<form method="post" action="{{ url_for('web.cancel_pending_job', pending_id=pending.id) }}" id="cancel-form"></form>
<div class="modal" data-role="voice-modal" hidden>
<div class="modal__overlay" data-role="voice-modal-close" tabindex="-1"></div>
<div class="modal__content voice-browser" role="dialog" aria-modal="true" aria-labelledby="voice-modal-title">
<header class="voice-browser__header">
<h2 id="voice-modal-title">Choose a voice</h2>
<button type="button" class="icon-button" data-role="voice-modal-close" aria-label="Close voice browser"></button>
</header>
<div class="voice-browser__body">
<aside class="voice-browser__filters">
<label class="field">
<span>Search</span>
<input type="search" data-role="voice-modal-search" placeholder="Search by name or language">
</label>
<label class="field">
<span>Language</span>
<select data-role="voice-modal-language">
<option value="">All languages</option>
{% for code, label in options.languages.items() %}
<option value="{{ code }}">{{ label }} ({{ code|upper }})</option>
{% endfor %}
</select>
</label>
<div class="voice-browser__gender" role="group" aria-label="Filter by gender">
<button type="button" class="button button--ghost button--small" data-role="voice-modal-gender" data-value="">All</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>
</div>
</aside>
<section class="voice-browser__catalog" aria-label="Voice catalog">
<ul class="voice-browser__list" data-role="voice-modal-list"></ul>
</section>
<section class="voice-browser__preview" data-role="voice-modal-preview">
<h3 data-role="voice-modal-selected-name">Select a voice to preview</h3>
<p class="tag" data-role="voice-modal-selected-meta"></p>
<div class="voice-browser__samples" data-role="voice-modal-samples"></div>
<div class="voice-browser__actions">
<button type="button" class="button" data-role="voice-modal-apply" disabled>Use this voice</button>
</div>
</section>
</div>
</div>
</div>
</section>
{% endblock %}
{% block scripts %}
{{ super() }}
<script id="voice-catalog-data" type="application/json">{{ options.voice_catalog | tojson }}</script>
<script id="voice-language-map" type="application/json">{{ options.languages | tojson }}</script>
<script type="module" src="{{ url_for('static', filename='speakers.js') }}"></script>
<script type="module" src="{{ url_for('static', filename='prepare.js') }}"></script>
{% endblock %}
+10
View File
@@ -102,6 +102,16 @@
{% endfor %}
</select>
</div>
<div class="field">
<label for="speaker_random_languages">Randomizer Languages</label>
{% set selected_languages = settings.speaker_random_languages or [] %}
<select id="speaker_random_languages" name="speaker_random_languages" multiple size="6">
{% for code, label in options.languages.items() %}
<option value="{{ code }}" {% if code in selected_languages %}selected{% endif %}>{{ label }} ({{ code|upper }})</option>
{% endfor %}
</select>
<p class="hint">Limits random voice selection for speakers marked as random. Leave unused to allow any language.</p>
</div>
<div class="field">
<label for="speaker_analysis_threshold">Speaker Analysis Minimum Mentions</label>
<input type="number" min="1" max="25" id="speaker_analysis_threshold" name="speaker_analysis_threshold" value="{{ settings.speaker_analysis_threshold }}">
+1 -21
View File
@@ -1,7 +1,6 @@
{% extends "base.html" %}
{% macro speaker_row(row_key, speaker, options) %}
{% set languages = speaker.languages or speaker.config_languages or [] %}
{% set gender = (speaker.gender or 'unknown') %}
{% set randomize = speaker.randomize %}
<div class="speaker-config-row" data-role="speaker-row" data-row-id="{{ row_key }}">
@@ -29,15 +28,6 @@
{% endfor %}
</select>
</label>
<label class="field" for="speaker-{{ row_key }}-languages">
<span>Allowed languages</span>
<select id="speaker-{{ row_key }}-languages" name="speaker-{{ row_key }}-languages" multiple size="4">
{% for code, label in options.languages.items() %}
<option value="{{ code }}" {% if code in languages %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
<p class="hint">Limit randomization to specific TTS language families.</p>
</label>
</div>
<div class="speaker-config-row__footer">
<label class="toggle-pill">
@@ -115,16 +105,6 @@
{% endfor %}
</select>
</label>
<label class="field" for="config_languages">
<span>Allowed languages</span>
{% set allowed_languages = editing.languages or [] %}
<select id="config_languages" name="config_languages" multiple size="5">
{% for code, label in options.languages.items() %}
<option value="{{ code }}" {% if code in allowed_languages %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
<p class="hint">Optional: restrict auto-randomized voices to these languages.</p>
</label>
<label class="field" for="config_default_voice">
<span>Fallback voice</span>
<select id="config_default_voice" name="config_default_voice">
@@ -167,7 +147,7 @@
</div>
<template id="speaker-row-template">
{{ speaker_row('__ROW__', {'id': '', 'label': '', 'gender': 'unknown', 'languages': [], 'voice': '', 'randomize': True}, options) | safe }}
{{ speaker_row('__ROW__', {'id': '', 'label': '', 'gender': 'unknown', 'voice': '', 'randomize': True}, options) | safe }}
</template>
</section>
{% endblock %}