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]:
|
def _count_gender_votes(text: str) -> Tuple[int, int]:
|
||||||
if not text:
|
if not text:
|
||||||
return 0, 0
|
return 0, 0
|
||||||
male = len(_MALE_PRONOUN_PATTERN.findall(text))
|
|
||||||
female = len(_FEMALE_PRONOUN_PATTERN.findall(text))
|
male_votes = 0.0
|
||||||
return male, female
|
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:
|
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,
|
allow_randomize: bool = False,
|
||||||
persist_changes: bool = False,
|
persist_changes: bool = False,
|
||||||
|
fallback_languages: Optional[Iterable[str]] = None,
|
||||||
) -> Tuple[Dict[str, Any], List[str], Optional[Dict[str, Any]]]:
|
) -> Tuple[Dict[str, Any], List[str], Optional[Dict[str, Any]]]:
|
||||||
if not isinstance(roster, Mapping):
|
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)}
|
updated_roster: Dict[str, Any] = {key: dict(value) for key, value in roster.items() if isinstance(value, Mapping)}
|
||||||
if not config:
|
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")
|
speakers_map = config.get("speakers")
|
||||||
if not isinstance(speakers_map, Mapping):
|
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")
|
config_languages = config.get("languages")
|
||||||
if isinstance(config_languages, list):
|
if isinstance(config_languages, list):
|
||||||
allowed_languages = [code for code in config_languages if isinstance(code, str) and code]
|
allowed_languages = [code for code in config_languages if isinstance(code, str) and code]
|
||||||
else:
|
else:
|
||||||
allowed_languages = []
|
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 ""
|
default_voice = config.get("default_voice") if isinstance(config.get("default_voice"), str) else ""
|
||||||
used_voices = {entry.get("resolved_voice") or entry.get("voice") for entry in updated_roster.values()} - {None}
|
used_voices = {entry.get("resolved_voice") or entry.get("voice") for entry in updated_roster.values()} - {None}
|
||||||
|
|
||||||
|
narrator_voice = ""
|
||||||
|
narrator_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
|
config_changed = False
|
||||||
new_config_payload: Dict[str, Any] = {
|
new_config_payload: Dict[str, Any] = {
|
||||||
"language": config.get("language", "a"),
|
"language": config.get("language", "a"),
|
||||||
@@ -236,11 +254,13 @@ def _apply_speaker_config_to_roster(
|
|||||||
randomize_flag = bool(config_entry.get("randomize"))
|
randomize_flag = bool(config_entry.get("randomize"))
|
||||||
|
|
||||||
chosen_voice = resolved_voice or voice_formula or voice_id or roster_entry.get("voice")
|
chosen_voice = resolved_voice or voice_formula or voice_id or roster_entry.get("voice")
|
||||||
if allow_randomize and randomize_flag:
|
usable_languages = languages or allowed_languages
|
||||||
exclusion = used_voices | {voice_id, resolved_voice}
|
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(
|
randomized = random_voice(
|
||||||
gender=config_entry.get("gender", "unknown"),
|
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"),
|
fallback_voice=default_voice or roster_entry.get("voice"),
|
||||||
exclude=exclusion,
|
exclude=exclusion,
|
||||||
)
|
)
|
||||||
@@ -249,6 +269,7 @@ def _apply_speaker_config_to_roster(
|
|||||||
voice_id = randomized
|
voice_id = randomized
|
||||||
resolved_voice = randomized
|
resolved_voice = randomized
|
||||||
config_changed = True
|
config_changed = True
|
||||||
|
used_voices.add(randomized)
|
||||||
|
|
||||||
if chosen_voice:
|
if chosen_voice:
|
||||||
roster_entry["resolved_voice"] = chosen_voice
|
roster_entry["resolved_voice"] = chosen_voice
|
||||||
@@ -260,7 +281,10 @@ def _apply_speaker_config_to_roster(
|
|||||||
roster_entry["resolved_voice"] = voice_formula
|
roster_entry["resolved_voice"] = voice_formula
|
||||||
if not voice_formula and not voice_profile and resolved_voice:
|
if not voice_formula and not voice_profile and resolved_voice:
|
||||||
roster_entry["resolved_voice"] = 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
|
# persist updates back to config payload if required
|
||||||
if persist_changes:
|
if persist_changes:
|
||||||
@@ -273,7 +297,7 @@ def _apply_speaker_config_to_roster(
|
|||||||
"voice_profile": voice_profile,
|
"voice_profile": voice_profile,
|
||||||
"voice_formula": voice_formula,
|
"voice_formula": voice_formula,
|
||||||
"resolved_voice": roster_entry.get("resolved_voice", resolved_voice or voice_id),
|
"resolved_voice": roster_entry.get("resolved_voice", resolved_voice or voice_id),
|
||||||
"languages": languages,
|
"languages": usable_languages,
|
||||||
"randomize": randomize_flag,
|
"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()
|
name = (form.get("config_name") or "").strip()
|
||||||
language = str(form.get("config_language") or "a").strip() or "a"
|
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()
|
default_voice = (form.get("config_default_voice") or "").strip()
|
||||||
notes = (form.get("config_notes") or "").strip()
|
notes = (form.get("config_notes") or "").strip()
|
||||||
version = _coerce_int(form.get("config_version"), 1, minimum=1, maximum=9999)
|
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_profile = (form.get(prefix + "profile") or "").strip()
|
||||||
voice_formula = (form.get(prefix + "formula") or "").strip()
|
voice_formula = (form.get(prefix + "formula") or "").strip()
|
||||||
randomize_flag = form.get(prefix + "randomize") in {"on", "1", "true"}
|
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)
|
speaker_id = (form.get(prefix + "id") or "").strip() or slugify_label(label)
|
||||||
speakers[speaker_id] = {
|
speakers[speaker_id] = {
|
||||||
"id": 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_profile": voice_profile,
|
||||||
"voice_formula": voice_formula,
|
"voice_formula": voice_formula,
|
||||||
"resolved_voice": voice_formula or voice,
|
"resolved_voice": voice_formula or voice,
|
||||||
"languages": languages,
|
"languages": [],
|
||||||
"randomize": randomize_flag,
|
"randomize": randomize_flag,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -443,6 +466,12 @@ def _prepare_speaker_metadata(
|
|||||||
analysis_source = [dict(chunk) for chunk in (analysis_chunks or chunks)]
|
analysis_source = [dict(chunk) for chunk in (analysis_chunks or chunks)]
|
||||||
threshold_value = max(1, int(threshold))
|
threshold_value = max(1, int(threshold))
|
||||||
analysis_enabled = speaker_mode == "multi" and run_analysis
|
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:
|
if not analysis_enabled:
|
||||||
for chunk in chunk_list:
|
for chunk in chunk_list:
|
||||||
@@ -534,6 +563,7 @@ def _prepare_speaker_metadata(
|
|||||||
speaker_config,
|
speaker_config,
|
||||||
allow_randomize=allow_randomize,
|
allow_randomize=allow_randomize,
|
||||||
persist_changes=persist_config,
|
persist_changes=persist_config,
|
||||||
|
fallback_languages=global_random_languages,
|
||||||
)
|
)
|
||||||
speakers_payload = analysis_payload.get("speakers")
|
speakers_payload = analysis_payload.get("speakers")
|
||||||
if isinstance(speakers_payload, dict):
|
if isinstance(speakers_payload, dict):
|
||||||
@@ -544,8 +574,18 @@ def _prepare_speaker_metadata(
|
|||||||
value = roster_payload.get(key)
|
value = roster_payload.get(key)
|
||||||
if value:
|
if value:
|
||||||
speaker_meta[key] = value
|
speaker_meta[key] = value
|
||||||
|
effective_languages: List[str] = []
|
||||||
if applied_languages:
|
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")
|
speakers_payload = analysis_payload.get("speakers")
|
||||||
if isinstance(speakers_payload, dict):
|
if isinstance(speakers_payload, dict):
|
||||||
for roster_id, roster_payload in roster.items():
|
for roster_id, roster_payload in roster.items():
|
||||||
@@ -554,11 +594,7 @@ def _prepare_speaker_metadata(
|
|||||||
if pronunciation_value:
|
if pronunciation_value:
|
||||||
speakers_payload[roster_id]["pronunciation"] = pronunciation_value
|
speakers_payload[roster_id]["pronunciation"] = pronunciation_value
|
||||||
|
|
||||||
fallback_languages = applied_languages or []
|
fallback_languages = effective_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)]
|
|
||||||
_inject_recommended_voices(roster, fallback_languages=fallback_languages)
|
_inject_recommended_voices(roster, fallback_languages=fallback_languages)
|
||||||
|
|
||||||
for chunk in chunk_list:
|
for chunk in chunk_list:
|
||||||
@@ -962,6 +998,7 @@ def _settings_defaults() -> Dict[str, Any]:
|
|||||||
"generate_epub3": False,
|
"generate_epub3": False,
|
||||||
"speaker_analysis_threshold": _DEFAULT_ANALYSIS_THRESHOLD,
|
"speaker_analysis_threshold": _DEFAULT_ANALYSIS_THRESHOLD,
|
||||||
"speaker_pronunciation_sentence": "This is {{name}} speaking.",
|
"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:
|
if isinstance(value, str) and value in _SPEAKER_MODE_VALUES:
|
||||||
return value
|
return value
|
||||||
return defaults[key]
|
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)
|
return value if value is not None else defaults.get(key)
|
||||||
|
|
||||||
|
|
||||||
@@ -1195,12 +1239,13 @@ def index() -> str:
|
|||||||
"index.html",
|
"index.html",
|
||||||
options=_template_options(),
|
options=_template_options(),
|
||||||
settings=_load_settings(),
|
settings=_load_settings(),
|
||||||
|
jobs_panel=_render_jobs_panel(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@web_bp.get("/queue")
|
@web_bp.get("/queue")
|
||||||
def queue_page() -> str:
|
def queue_page() -> ResponseReturnValue:
|
||||||
return render_template("queue.html", jobs_panel=_render_jobs_panel())
|
return redirect(url_for("web.index", _anchor="queue"))
|
||||||
|
|
||||||
|
|
||||||
@web_bp.route("/settings", methods=["GET", "POST"])
|
@web_bp.route("/settings", methods=["GET", "POST"])
|
||||||
@@ -1256,6 +1301,13 @@ def settings_page() -> ResponseReturnValue:
|
|||||||
sentence_value = defaults["speaker_pronunciation_sentence"]
|
sentence_value = defaults["speaker_pronunciation_sentence"]
|
||||||
updated["speaker_pronunciation_sentence"] = sentence_value
|
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 = load_config() or {}
|
||||||
cfg.update(updated)
|
cfg.update(updated)
|
||||||
save_config(cfg)
|
save_config(cfg)
|
||||||
@@ -2126,7 +2178,7 @@ def finalize_job(pending_id: str) -> ResponseReturnValue:
|
|||||||
job.applied_speaker_config = config_name
|
job.applied_speaker_config = config_name
|
||||||
job.speaker_voice_languages = job.speaker_voice_languages or list(pending.speaker_voice_languages)
|
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")
|
@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()
|
pending.cover_image_path.unlink()
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
return redirect(url_for("web.index"))
|
return redirect(url_for("web.index", _anchor="queue"))
|
||||||
|
|
||||||
|
|
||||||
def _render_jobs_panel() -> str:
|
def _render_jobs_panel() -> str:
|
||||||
@@ -2225,7 +2277,7 @@ def clear_finished_jobs() -> ResponseReturnValue:
|
|||||||
_service().clear_finished()
|
_service().clear_finished()
|
||||||
if request.headers.get("HX-Request"):
|
if request.headers.get("HX-Request"):
|
||||||
return _render_jobs_panel()
|
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")
|
@web_bp.get("/jobs/<job_id>/download")
|
||||||
|
|||||||
@@ -2,6 +2,22 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
const form = document.querySelector(".prepare-form");
|
const form = document.querySelector(".prepare-form");
|
||||||
if (!form) return;
|
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 chapterRows = Array.from(form.querySelectorAll("[data-role=chapter-row]"));
|
||||||
|
|
||||||
const updateRowState = (row) => {
|
const updateRowState = (row) => {
|
||||||
@@ -123,6 +139,9 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
handleRandomizeToggle(randomToggle);
|
handleRandomizeToggle(randomToggle);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!target.dataset.suppressRandomize) {
|
||||||
|
target.dataset.prevManual = target.value || "";
|
||||||
|
}
|
||||||
updatePreviewVoice(target);
|
updatePreviewVoice(target);
|
||||||
});
|
});
|
||||||
updatePreviewVoice(select);
|
updatePreviewVoice(select);
|
||||||
@@ -134,7 +153,435 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
checkbox.addEventListener("change", () => handleRandomizeToggle(checkbox));
|
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) => {
|
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"]');
|
const chip = event.target.closest('[data-role="recommended-voice"]');
|
||||||
if (!chip) return;
|
if (!chip) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -147,7 +594,11 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
randomToggle.checked = false;
|
randomToggle.checked = false;
|
||||||
handleRandomizeToggle(randomToggle);
|
handleRandomizeToggle(randomToggle);
|
||||||
}
|
}
|
||||||
|
select.disabled = false;
|
||||||
|
select.dataset.suppressRandomize = "1";
|
||||||
select.value = chip.dataset.voice || "";
|
select.value = chip.dataset.voice || "";
|
||||||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
|
delete select.dataset.suppressRandomize;
|
||||||
|
select.dataset.prevManual = select.value || "";
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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.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.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.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>
|
<a href="{{ url_for('web.settings_page') }}" class="btn{% if endpoint == 'web.settings_page' %} is-active{% endif %}">Settings</a>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -7,15 +7,15 @@
|
|||||||
<div class="step-indicator" aria-label="Audiobook workflow">
|
<div class="step-indicator" aria-label="Audiobook workflow">
|
||||||
<span class="step-indicator__item is-active">
|
<span class="step-indicator__item is-active">
|
||||||
<span class="step-indicator__index">1</span>
|
<span class="step-indicator__index">1</span>
|
||||||
<span class="step-indicator__label">Upload</span>
|
<span class="step-indicator__label">Upload & settings</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="step-indicator__item">
|
<span class="step-indicator__item">
|
||||||
<span class="step-indicator__index">2</span>
|
<span class="step-indicator__index">2</span>
|
||||||
<span class="step-indicator__label">Speakers</span>
|
<span class="step-indicator__label">Chapters</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="step-indicator__item">
|
<span class="step-indicator__item">
|
||||||
<span class="step-indicator__index">3</span>
|
<span class="step-indicator__index">3</span>
|
||||||
<span class="step-indicator__label">Queue</span>
|
<span class="step-indicator__label">Speakers</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="card__title">Create a New Audiobook</h1>
|
<h1 class="card__title">Create a New Audiobook</h1>
|
||||||
@@ -132,6 +132,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</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 %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
|
|||||||
@@ -3,19 +3,19 @@
|
|||||||
{% block title %}Prepare · {{ pending.original_filename }}{% endblock %}
|
{% block title %}Prepare · {{ pending.original_filename }}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="card">
|
<section class="card" data-role="prepare-wizard">
|
||||||
<div class="step-indicator" aria-label="Audiobook workflow">
|
<div class="step-indicator" aria-label="Audiobook workflow" data-role="wizard-indicator">
|
||||||
<span class="step-indicator__item is-complete">
|
<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__index">1</span>
|
||||||
<span class="step-indicator__label">Upload</span>
|
<span class="step-indicator__label">Upload & settings</span>
|
||||||
</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__index">2</span>
|
||||||
<span class="step-indicator__label">Speakers</span>
|
<span class="step-indicator__label">Chapters</span>
|
||||||
</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__index">3</span>
|
||||||
<span class="step-indicator__label">Queue</span>
|
<span class="step-indicator__label">Speakers</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card__title">Prepare audiobook</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>
|
<p>Press <strong>Analyze speakers</strong> after selecting chapters to discover recurring voices.</p>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -134,178 +124,17 @@
|
|||||||
<div class="alert alert--info">{{ notice }}</div>
|
<div class="alert alert--info">{{ notice }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="prepare-speaker-config">
|
<div class="prepare-step-nav" data-role="prepare-step-nav">
|
||||||
<div class="prepare-speaker-config__header">
|
<button type="button" class="button button--ghost is-active" data-step-target="chapters">Step 2 · Chapters</button>
|
||||||
<h2>Speaker configuration</h2>
|
<button type="button" class="button button--ghost" data-step-target="speakers" disabled aria-disabled="true" data-state="locked">Step 3 · Speakers</button>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
{% if roster %}
|
<div class="prepare-steps" data-role="prepare-step-panels">
|
||||||
<div class="prepare-speakers">
|
<section class="prepare-step" data-step-panel="chapters">
|
||||||
<h2>Speaker settings</h2>
|
<header class="prepare-step__header">
|
||||||
<p class="hint">Set pronunciations, lock specific voices, or limit the randomizer by language and gender.</p>
|
<h2>Step 2 · Select chapters</h2>
|
||||||
<ul class="speaker-list">
|
<p class="hint">Choose which chapters to convert and tweak conversion options.</p>
|
||||||
{% for speaker_id, speaker in roster.items() %}
|
</header>
|
||||||
{% 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="chapter-grid">
|
<div class="chapter-grid">
|
||||||
{% for chapter in pending.chapters %}
|
{% for chapter in pending.chapters %}
|
||||||
@@ -402,7 +231,224 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<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"
|
<button type="submit"
|
||||||
class="button button--ghost"
|
class="button button--ghost"
|
||||||
data-role="analyze-button"
|
data-role="analyze-button"
|
||||||
@@ -415,13 +461,59 @@
|
|||||||
<button type="submit" class="button">Queue conversion</button>
|
<button type="submit" class="button">Queue conversion</button>
|
||||||
<button type="submit" class="button button--ghost" form="cancel-form">Cancel</button>
|
<button type="submit" class="button button--ghost" form="cancel-form">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<form method="post" action="{{ url_for('web.cancel_pending_job', pending_id=pending.id) }}" id="cancel-form"></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>
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
{{ super() }}
|
{{ 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='speakers.js') }}"></script>
|
||||||
<script type="module" src="{{ url_for('static', filename='prepare.js') }}"></script>
|
<script type="module" src="{{ url_for('static', filename='prepare.js') }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -102,6 +102,16 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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">
|
<div class="field">
|
||||||
<label for="speaker_analysis_threshold">Speaker Analysis Minimum Mentions</label>
|
<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 }}">
|
<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" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% macro speaker_row(row_key, speaker, options) %}
|
{% macro speaker_row(row_key, speaker, options) %}
|
||||||
{% set languages = speaker.languages or speaker.config_languages or [] %}
|
|
||||||
{% set gender = (speaker.gender or 'unknown') %}
|
{% set gender = (speaker.gender or 'unknown') %}
|
||||||
{% set randomize = speaker.randomize %}
|
{% set randomize = speaker.randomize %}
|
||||||
<div class="speaker-config-row" data-role="speaker-row" data-row-id="{{ row_key }}">
|
<div class="speaker-config-row" data-role="speaker-row" data-row-id="{{ row_key }}">
|
||||||
@@ -29,15 +28,6 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</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>
|
||||||
<div class="speaker-config-row__footer">
|
<div class="speaker-config-row__footer">
|
||||||
<label class="toggle-pill">
|
<label class="toggle-pill">
|
||||||
@@ -115,16 +105,6 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</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">
|
<label class="field" for="config_default_voice">
|
||||||
<span>Fallback voice</span>
|
<span>Fallback voice</span>
|
||||||
<select id="config_default_voice" name="config_default_voice">
|
<select id="config_default_voice" name="config_default_voice">
|
||||||
@@ -167,7 +147,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template id="speaker-row-template">
|
<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>
|
</template>
|
||||||
</section>
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user