mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +02:00
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:
@@ -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
@@ -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")
|
||||
|
||||
@@ -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 || "";
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,11 +17,11 @@
|
||||
</div>
|
||||
<nav class="top-actions">
|
||||
{% set endpoint = request.endpoint or '' %}
|
||||
<a href="{{ url_for('web.index') }}" class="btn{% if endpoint == 'web.index' %} is-active{% endif %}">Dashboard</a>
|
||||
<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.settings_page') }}" class="btn{% if endpoint == 'web.settings_page' %} is-active{% endif %}">Settings</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.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>
|
||||
<main class="main">
|
||||
|
||||
@@ -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 & 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 %}
|
||||
|
||||
@@ -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 & 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,294 +124,396 @@
|
||||
<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 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 %}
|
||||
{% set is_enabled = chapter.enabled is not defined or chapter.enabled %}
|
||||
{% set selected_option = '__default' %}
|
||||
{% if chapter.voice_profile %}
|
||||
{% set selected_option = 'profile:' ~ chapter.voice_profile %}
|
||||
{% elif chapter.voice %}
|
||||
{% set selected_option = 'voice:' ~ chapter.voice %}
|
||||
{% elif chapter.voice_formula %}
|
||||
{% set selected_option = 'formula' %}
|
||||
{% endif %}
|
||||
<article class="chapter-card" data-role="chapter-row">
|
||||
<header class="chapter-card__header">
|
||||
<div class="chapter-card__title">
|
||||
<label class="chapter-card__checkbox">
|
||||
<input type="checkbox" name="chapter-{{ loop.index0 }}-enabled" data-role="chapter-enabled" {% if is_enabled %}checked{% endif %}>
|
||||
<span>Chapter {{ loop.index }} · {{ chapter.title }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="chapter-card__metrics">
|
||||
{{ '{:,}'.format(chapter.characters) }} characters
|
||||
</div>
|
||||
</header>
|
||||
<div class="chapter-card__body">
|
||||
<div class="chapter-card__field">
|
||||
<label for="chapter-{{ loop.index0 }}-title">Title</label>
|
||||
<input type="text" id="chapter-{{ loop.index0 }}-title" name="chapter-{{ loop.index0 }}-title" value="{{ chapter.title }}">
|
||||
</div>
|
||||
<div class="chapter-card__preview">
|
||||
<details>
|
||||
<summary>Preview text</summary>
|
||||
<pre>{{ chapter.text[:2000] }}{% if chapter.text|length > 2000 %}…{% endif %}</pre>
|
||||
</details>
|
||||
</div>
|
||||
<div class="chapter-card__field">
|
||||
<label for="chapter-{{ loop.index0 }}-voice">Voice override</label>
|
||||
<select id="chapter-{{ loop.index0 }}-voice" name="chapter-{{ loop.index0 }}-voice" data-role="voice-select">
|
||||
<option value="__default" {% if selected_option == '__default' %}selected{% endif %}>Use job default</option>
|
||||
<optgroup label="Voices">
|
||||
{% for voice in options.voices %}
|
||||
<option value="voice:{{ voice }}" {% if selected_option == 'voice:' ~ voice %}selected{% endif %}>{{ voice }}</option>
|
||||
{% endfor %}
|
||||
</optgroup>
|
||||
{% if options.voice_profile_options %}
|
||||
<optgroup label="Profiles">
|
||||
{% for profile in options.voice_profile_options %}
|
||||
<option value="profile:{{ profile.name }}" {% if selected_option == 'profile:' ~ profile.name %}selected{% endif %}>{{ profile.name }}{% if profile.language %} · {{ profile.language|upper }}{% endif %}</option>
|
||||
{% endfor %}
|
||||
</optgroup>
|
||||
{% endif %}
|
||||
<option value="formula" {% if selected_option == 'formula' %}selected{% endif %}>Custom formula…</option>
|
||||
</select>
|
||||
<input type="text" name="chapter-{{ loop.index0 }}-formula" class="chapter-card__formula" data-role="formula-input" placeholder="af_nova*0.4+am_liam*0.6" value="{{ chapter.voice_formula or '' }}" {% if selected_option != 'formula' %}hidden aria-hidden="true"{% else %}aria-hidden="false"{% endif %}>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="prepare-options">
|
||||
<div class="field">
|
||||
<label for="chunk_level">Chunk granularity</label>
|
||||
<select id="chunk_level" name="chunk_level">
|
||||
{% for option in options.chunk_levels %}
|
||||
<option value="{{ option.value }}" {% if pending.chunk_level == option.value %}selected{% endif %}>{{ option.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="hint">Paragraphs work well for long-form narration; sentences give finer subtitle sync.</p>
|
||||
</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 class="field">
|
||||
<label for="speaker_mode">Speaker mode</label>
|
||||
<select id="speaker_mode" name="speaker_mode">
|
||||
{% for option in options.speaker_modes %}
|
||||
<option value="{{ option.value }}" {% if pending.speaker_mode == option.value %}selected{% endif %}>{{ option.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</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>
|
||||
<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="{{ pending.speaker_analysis_threshold }}">
|
||||
<p class="hint">Only speakers that appear at least this many times will keep unique voices in multi-speaker mode.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="chapter_intro_delay">Pause after chapter titles (seconds)</label>
|
||||
<input type="number" step="0.1" min="0" id="chapter_intro_delay" name="chapter_intro_delay" value="{{ '%.2f'|format(pending.chapter_intro_delay) }}">
|
||||
<p class="hint">Set to 0 to disable the pause after speaking each chapter title.</p>
|
||||
</div>
|
||||
<div class="field field--choices">
|
||||
<label class="toggle-pill">
|
||||
<input type="checkbox" name="generate_epub3" value="true" {% if pending.generate_epub3 %}checked{% endif %}>
|
||||
<span>Generate EPUB 3 (experimental)</span>
|
||||
</label>
|
||||
<div class="speaker-list__inline">
|
||||
</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="speaker-{{ speaker_id }}-randomize"
|
||||
value="1"
|
||||
data-role="randomize-toggle"
|
||||
{% if speaker.randomize %}checked{% endif %}>
|
||||
<span>Randomize compatible voice on analyze</span>
|
||||
<input type="checkbox" name="save_speaker_config" value="1">
|
||||
<span>Save roster updates back to preset</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="chapter-grid">
|
||||
{% for chapter in pending.chapters %}
|
||||
{% set is_enabled = chapter.enabled is not defined or chapter.enabled %}
|
||||
{% set selected_option = '__default' %}
|
||||
{% if chapter.voice_profile %}
|
||||
{% set selected_option = 'profile:' ~ chapter.voice_profile %}
|
||||
{% elif chapter.voice %}
|
||||
{% set selected_option = 'voice:' ~ chapter.voice %}
|
||||
{% elif chapter.voice_formula %}
|
||||
{% set selected_option = 'formula' %}
|
||||
{% endif %}
|
||||
<article class="chapter-card" data-role="chapter-row">
|
||||
<header class="chapter-card__header">
|
||||
<div class="chapter-card__title">
|
||||
<label class="chapter-card__checkbox">
|
||||
<input type="checkbox" name="chapter-{{ loop.index0 }}-enabled" data-role="chapter-enabled" {% if is_enabled %}checked{% endif %}>
|
||||
<span>Chapter {{ loop.index }} · {{ chapter.title }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="chapter-card__metrics">
|
||||
{{ '{:,}'.format(chapter.characters) }} characters
|
||||
</div>
|
||||
</header>
|
||||
<div class="chapter-card__body">
|
||||
<div class="chapter-card__field">
|
||||
<label for="chapter-{{ loop.index0 }}-title">Title</label>
|
||||
<input type="text" id="chapter-{{ loop.index0 }}-title" name="chapter-{{ loop.index0 }}-title" value="{{ chapter.title }}">
|
||||
</div>
|
||||
<div class="chapter-card__preview">
|
||||
<details>
|
||||
<summary>Preview text</summary>
|
||||
<pre>{{ chapter.text[:2000] }}{% if chapter.text|length > 2000 %}…{% endif %}</pre>
|
||||
</details>
|
||||
</div>
|
||||
<div class="chapter-card__field">
|
||||
<label for="chapter-{{ loop.index0 }}-voice">Voice override</label>
|
||||
<select id="chapter-{{ loop.index0 }}-voice" name="chapter-{{ loop.index0 }}-voice" data-role="voice-select">
|
||||
<option value="__default" {% if selected_option == '__default' %}selected{% endif %}>Use job default</option>
|
||||
<optgroup label="Voices">
|
||||
{% for voice in options.voices %}
|
||||
<option value="voice:{{ voice }}" {% if selected_option == 'voice:' ~ voice %}selected{% endif %}>{{ voice }}</option>
|
||||
{% endfor %}
|
||||
</optgroup>
|
||||
{% if options.voice_profile_options %}
|
||||
<optgroup label="Profiles">
|
||||
{% for profile in options.voice_profile_options %}
|
||||
<option value="profile:{{ profile.name }}" {% if selected_option == 'profile:' ~ profile.name %}selected{% endif %}>{{ profile.name }}{% if profile.language %} · {{ profile.language|upper }}{% endif %}</option>
|
||||
{% endfor %}
|
||||
</optgroup>
|
||||
{% endif %}
|
||||
<option value="formula" {% if selected_option == 'formula' %}selected{% endif %}>Custom formula…</option>
|
||||
</select>
|
||||
<input type="text" name="chapter-{{ loop.index0 }}-formula" class="chapter-card__formula" data-role="formula-input" placeholder="af_nova*0.4+am_liam*0.6" value="{{ chapter.voice_formula or '' }}" {% if selected_option != 'formula' %}hidden aria-hidden="true"{% else %}aria-hidden="false"{% endif %}>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="prepare-options">
|
||||
<div class="field">
|
||||
<label for="chunk_level">Chunk granularity</label>
|
||||
<select id="chunk_level" name="chunk_level">
|
||||
{% for option in options.chunk_levels %}
|
||||
<option value="{{ option.value }}" {% if pending.chunk_level == option.value %}selected{% endif %}>{{ option.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="hint">Paragraphs work well for long-form narration; sentences give finer subtitle sync.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="speaker_mode">Speaker mode</label>
|
||||
<select id="speaker_mode" name="speaker_mode">
|
||||
{% for option in options.speaker_modes %}
|
||||
<option value="{{ option.value }}" {% if pending.speaker_mode == option.value %}selected{% endif %}>{{ option.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</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="{{ pending.speaker_analysis_threshold }}">
|
||||
<p class="hint">Only speakers that appear at least this many times will keep unique voices in multi-speaker mode.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="chapter_intro_delay">Pause after chapter titles (seconds)</label>
|
||||
<input type="number" step="0.1" min="0" id="chapter_intro_delay" name="chapter_intro_delay" value="{{ '%.2f'|format(pending.chapter_intro_delay) }}">
|
||||
<p class="hint">Set to 0 to disable the pause after speaking each chapter title.</p>
|
||||
</div>
|
||||
<div class="field field--choices">
|
||||
<label class="toggle-pill">
|
||||
<input type="checkbox" name="generate_epub3" value="true" {% if pending.generate_epub3 %}checked{% endif %}>
|
||||
<span>Generate EPUB 3 (experimental)</span>
|
||||
</label>
|
||||
</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="submit"
|
||||
class="button button--ghost"
|
||||
data-role="analyze-button"
|
||||
formaction="{{ url_for('web.analyze_pending_job', pending_id=pending.id) }}"
|
||||
formmethod="post"
|
||||
formnovalidate
|
||||
{% if pending.speaker_mode != 'multi' %}hidden aria-hidden="true"{% else %}aria-hidden="false"{% endif %}>
|
||||
Analyze speakers
|
||||
</button>
|
||||
<button type="submit" class="button">Queue conversion</button>
|
||||
<button type="submit" class="button button--ghost" form="cancel-form">Cancel</button>
|
||||
<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"
|
||||
formaction="{{ url_for('web.analyze_pending_job', pending_id=pending.id) }}"
|
||||
formmethod="post"
|
||||
formnovalidate
|
||||
{% if pending.speaker_mode != 'multi' %}hidden aria-hidden="true"{% else %}aria-hidden="false"{% endif %}>
|
||||
Analyze speakers
|
||||
</button>
|
||||
<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 %}
|
||||
|
||||
@@ -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,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 %}
|
||||
|
||||
Reference in New Issue
Block a user