mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +02:00
feat: Refactor speaker configuration and UI components, enhance custom mix functionality, and improve styling for better usability
This commit is contained in:
@@ -2,11 +2,9 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import random
|
from typing import Any, Dict, List, Optional
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import Any, Dict, Iterable, List, Optional
|
|
||||||
|
|
||||||
from abogen.constants import LANGUAGE_DESCRIPTIONS, VOICES_INTERNAL
|
from abogen.constants import LANGUAGE_DESCRIPTIONS
|
||||||
from abogen.utils import get_user_config_path
|
from abogen.utils import get_user_config_path
|
||||||
|
|
||||||
_CONFIG_WRAPPER_KEY = "abogen_speaker_configs"
|
_CONFIG_WRAPPER_KEY = "abogen_speaker_configs"
|
||||||
@@ -135,7 +133,6 @@ def _sanitize_speaker(entry: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
for code in voice_languages:
|
for code in voice_languages:
|
||||||
if isinstance(code, str) and code:
|
if isinstance(code, str) and code:
|
||||||
normalized_langs.append(code.lower())
|
normalized_langs.append(code.lower())
|
||||||
randomize = bool(entry.get("randomize"))
|
|
||||||
resolved_voice = entry.get("resolved_voice") or voice_formula or voice
|
resolved_voice = entry.get("resolved_voice") or voice_formula or voice
|
||||||
resolved_label = label or entry.get("id") or ""
|
resolved_label = label or entry.get("id") or ""
|
||||||
slug = entry.get("id") if isinstance(entry.get("id"), str) else slugify_label(resolved_label)
|
slug = entry.get("id") if isinstance(entry.get("id"), str) else slugify_label(resolved_label)
|
||||||
@@ -148,7 +145,6 @@ def _sanitize_speaker(entry: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
"voice_formula": voice_formula if isinstance(voice_formula, str) else "",
|
"voice_formula": voice_formula if isinstance(voice_formula, str) else "",
|
||||||
"resolved_voice": resolved_voice if isinstance(resolved_voice, str) else "",
|
"resolved_voice": resolved_voice if isinstance(resolved_voice, str) else "",
|
||||||
"languages": normalized_langs,
|
"languages": normalized_langs,
|
||||||
"randomize": randomize,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -161,68 +157,6 @@ def list_configs() -> List[Dict[str, Any]]:
|
|||||||
return ordered
|
return ordered
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class RandomVoiceOptions:
|
|
||||||
gender: str
|
|
||||||
allowed_languages: List[str] = field(default_factory=list)
|
|
||||||
fallback_voice: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
def random_voice(
|
|
||||||
*,
|
|
||||||
gender: str,
|
|
||||||
allowed_languages: Optional[Iterable[str]] = None,
|
|
||||||
fallback_voice: Optional[str] = None,
|
|
||||||
exclude: Optional[Iterable[str]] = None,
|
|
||||||
) -> Optional[str]:
|
|
||||||
gender = (gender or "unknown").lower()
|
|
||||||
allowed = [code.lower() for code in (allowed_languages or []) if isinstance(code, str) and code]
|
|
||||||
excluded = {voice for voice in (exclude or []) if isinstance(voice, str)}
|
|
||||||
|
|
||||||
def _voice_gender(code: str) -> str:
|
|
||||||
if len(code) >= 2:
|
|
||||||
marker = code[1]
|
|
||||||
if marker == "m":
|
|
||||||
return "male"
|
|
||||||
if marker == "f":
|
|
||||||
return "female"
|
|
||||||
return "unknown"
|
|
||||||
|
|
||||||
def _voice_language(code: str) -> str:
|
|
||||||
return code[0] if code else "a"
|
|
||||||
|
|
||||||
candidates: List[str] = []
|
|
||||||
for voice in VOICES_INTERNAL:
|
|
||||||
if voice in excluded:
|
|
||||||
continue
|
|
||||||
voice_gender = _voice_gender(voice)
|
|
||||||
voice_language = _voice_language(voice)
|
|
||||||
if allowed and voice_language not in allowed:
|
|
||||||
continue
|
|
||||||
if gender in {"male", "female"} and voice_gender != gender:
|
|
||||||
continue
|
|
||||||
candidates.append(voice)
|
|
||||||
|
|
||||||
if not candidates and allowed:
|
|
||||||
# retry without language restriction, preserving gender
|
|
||||||
for voice in VOICES_INTERNAL:
|
|
||||||
if voice in excluded:
|
|
||||||
continue
|
|
||||||
voice_gender = _voice_gender(voice)
|
|
||||||
if gender in {"male", "female"} and voice_gender != gender:
|
|
||||||
continue
|
|
||||||
candidates.append(voice)
|
|
||||||
|
|
||||||
if not candidates:
|
|
||||||
candidates = [voice for voice in VOICES_INTERNAL if voice not in excluded]
|
|
||||||
|
|
||||||
if not candidates:
|
|
||||||
return fallback_voice
|
|
||||||
|
|
||||||
choice = random.choice(candidates)
|
|
||||||
return choice or fallback_voice
|
|
||||||
|
|
||||||
|
|
||||||
def describe_language(code: str) -> str:
|
def describe_language(code: str) -> str:
|
||||||
code = (code or "a").lower()
|
code = (code or "a").lower()
|
||||||
return LANGUAGE_DESCRIPTIONS.get(code, code.upper())
|
return LANGUAGE_DESCRIPTIONS.get(code, code.upper())
|
||||||
|
|||||||
+18
-51
@@ -64,7 +64,6 @@ from abogen.speaker_configs import (
|
|||||||
get_config,
|
get_config,
|
||||||
list_configs,
|
list_configs,
|
||||||
load_configs,
|
load_configs,
|
||||||
random_voice,
|
|
||||||
save_configs,
|
save_configs,
|
||||||
upsert_config,
|
upsert_config,
|
||||||
slugify_label,
|
slugify_label,
|
||||||
@@ -142,22 +141,34 @@ def _build_speaker_roster(
|
|||||||
payload = speakers.get(speaker_id, {})
|
payload = speakers.get(speaker_id, {})
|
||||||
if speaker_id == "narrator":
|
if speaker_id == "narrator":
|
||||||
continue
|
continue
|
||||||
if payload.get("suppressed"):
|
|
||||||
continue
|
continue
|
||||||
previous = existing_map.get(speaker_id)
|
previous = existing_map.get(speaker_id)
|
||||||
roster[speaker_id] = {
|
roster[speaker_id] = {
|
||||||
"id": speaker_id,
|
"id": speaker_id,
|
||||||
"label": payload.get("label") or speaker_id.replace("_", " ").title(),
|
"label": payload.get("label") or speaker_id.replace("_", " ").title(),
|
||||||
"voice": base_voice,
|
|
||||||
"analysis_confidence": payload.get("confidence"),
|
"analysis_confidence": payload.get("confidence"),
|
||||||
"analysis_count": payload.get("count"),
|
"analysis_count": payload.get("count"),
|
||||||
"gender": payload.get("gender", "unknown"),
|
"gender": payload.get("gender", "unknown"),
|
||||||
}
|
}
|
||||||
|
detected_gender = payload.get("detected_gender")
|
||||||
|
if detected_gender:
|
||||||
|
roster[speaker_id]["detected_gender"] = detected_gender
|
||||||
|
samples = payload.get("sample_quotes")
|
||||||
|
if isinstance(samples, list):
|
||||||
|
roster[speaker_id]["sample_quotes"] = samples
|
||||||
if isinstance(previous, Mapping):
|
if isinstance(previous, Mapping):
|
||||||
for key in ("voice", "voice_profile", "voice_formula", "resolved_voice", "pronunciation"):
|
for key in ("voice", "voice_profile", "voice_formula", "resolved_voice", "pronunciation"):
|
||||||
value = previous.get(key)
|
value = previous.get(key)
|
||||||
if value is not None and value != "":
|
if value is not None and value != "":
|
||||||
roster[speaker_id][key] = value
|
roster[speaker_id][key] = value
|
||||||
|
if "sample_quotes" not in roster[speaker_id]:
|
||||||
|
prev_samples = previous.get("sample_quotes")
|
||||||
|
if isinstance(prev_samples, list):
|
||||||
|
roster[speaker_id]["sample_quotes"] = prev_samples
|
||||||
|
if "detected_gender" not in roster[speaker_id]:
|
||||||
|
prev_detected = previous.get("detected_gender")
|
||||||
|
if isinstance(prev_detected, str) and prev_detected:
|
||||||
|
roster[speaker_id]["detected_gender"] = prev_detected
|
||||||
return roster
|
return roster
|
||||||
|
|
||||||
|
|
||||||
@@ -187,7 +198,6 @@ def _apply_speaker_config_to_roster(
|
|||||||
roster: Mapping[str, Any],
|
roster: Mapping[str, Any],
|
||||||
config: Optional[Mapping[str, Any]],
|
config: Optional[Mapping[str, Any]],
|
||||||
*,
|
*,
|
||||||
allow_randomize: bool = False,
|
|
||||||
persist_changes: bool = False,
|
persist_changes: bool = False,
|
||||||
fallback_languages: Optional[Iterable[str]] = None,
|
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]]]:
|
||||||
@@ -214,13 +224,11 @@ def _apply_speaker_config_to_roster(
|
|||||||
|
|
||||||
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_voice = ""
|
||||||
narrator_entry = updated_roster.get("narrator") if isinstance(updated_roster, Mapping) else None
|
narrator_entry = updated_roster.get("narrator") if isinstance(updated_roster, Mapping) else None
|
||||||
if isinstance(narrator_entry, Mapping):
|
if isinstance(narrator_entry, Mapping):
|
||||||
narrator_voice = str(
|
narrator_voice = str(
|
||||||
narrator_entry.get("resolved_voice")
|
narrator_entry.get("resolved_voice")
|
||||||
or narrator_entry.get("voice")
|
|
||||||
or narrator_entry.get("default_voice")
|
or narrator_entry.get("default_voice")
|
||||||
or ""
|
or ""
|
||||||
).strip()
|
).strip()
|
||||||
@@ -251,25 +259,8 @@ def _apply_speaker_config_to_roster(
|
|||||||
voice_formula = str(config_entry.get("voice_formula") or "").strip()
|
voice_formula = str(config_entry.get("voice_formula") or "").strip()
|
||||||
resolved_voice = str(config_entry.get("resolved_voice") or "").strip()
|
resolved_voice = str(config_entry.get("resolved_voice") or "").strip()
|
||||||
languages = config_entry.get("languages") if isinstance(config_entry.get("languages"), list) else []
|
languages = config_entry.get("languages") if isinstance(config_entry.get("languages"), list) else []
|
||||||
randomize_flag = bool(config_entry.get("randomize"))
|
|
||||||
|
|
||||||
chosen_voice = resolved_voice or voice_formula or voice_id or roster_entry.get("voice")
|
chosen_voice = resolved_voice or voice_formula or voice_id or roster_entry.get("voice")
|
||||||
usable_languages = languages or allowed_languages
|
usable_languages = languages or allowed_languages
|
||||||
should_randomize = randomize_flag and (allow_randomize or not chosen_voice)
|
|
||||||
if should_randomize:
|
|
||||||
exclusion = used_voices | {voice_id, resolved_voice, narrator_voice}
|
|
||||||
randomized = random_voice(
|
|
||||||
gender=config_entry.get("gender", "unknown"),
|
|
||||||
allowed_languages=usable_languages,
|
|
||||||
fallback_voice=default_voice or roster_entry.get("voice"),
|
|
||||||
exclude=exclusion,
|
|
||||||
)
|
|
||||||
if randomized:
|
|
||||||
chosen_voice = randomized
|
|
||||||
voice_id = randomized
|
|
||||||
resolved_voice = randomized
|
|
||||||
config_changed = True
|
|
||||||
used_voices.add(randomized)
|
|
||||||
|
|
||||||
if chosen_voice:
|
if chosen_voice:
|
||||||
roster_entry["resolved_voice"] = chosen_voice
|
roster_entry["resolved_voice"] = chosen_voice
|
||||||
@@ -298,7 +289,6 @@ def _apply_speaker_config_to_roster(
|
|||||||
"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": usable_languages,
|
"languages": usable_languages,
|
||||||
"randomize": randomize_flag,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
new_config = new_config_payload if (persist_changes and config_changed) else None
|
new_config = new_config_payload if (persist_changes and config_changed) else None
|
||||||
@@ -414,7 +404,6 @@ def _extract_speaker_config_form(form: Mapping[str, Any]) -> Tuple[str, Dict[str
|
|||||||
voice = (form.get(prefix + "voice") or "").strip()
|
voice = (form.get(prefix + "voice") or "").strip()
|
||||||
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"}
|
|
||||||
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,
|
||||||
@@ -425,7 +414,6 @@ def _extract_speaker_config_form(form: Mapping[str, Any]) -> Tuple[str, Dict[str
|
|||||||
"voice_formula": voice_formula,
|
"voice_formula": voice_formula,
|
||||||
"resolved_voice": voice_formula or voice,
|
"resolved_voice": voice_formula or voice,
|
||||||
"languages": [],
|
"languages": [],
|
||||||
"randomize": randomize_flag,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
@@ -459,7 +447,6 @@ def _prepare_speaker_metadata(
|
|||||||
run_analysis: bool = True,
|
run_analysis: bool = True,
|
||||||
speaker_config: Optional[Mapping[str, Any]] = None,
|
speaker_config: Optional[Mapping[str, Any]] = None,
|
||||||
apply_config: bool = False,
|
apply_config: bool = False,
|
||||||
allow_randomize: bool = False,
|
|
||||||
persist_config: bool = False,
|
persist_config: bool = False,
|
||||||
) -> tuple[List[Dict[str, Any]], Dict[str, Any], Dict[str, Any], List[str], Optional[Dict[str, Any]]]:
|
) -> tuple[List[Dict[str, Any]], Dict[str, Any], Dict[str, Any], List[str], Optional[Dict[str, Any]]]:
|
||||||
chunk_list = [dict(chunk) for chunk in chunks]
|
chunk_list = [dict(chunk) for chunk in chunks]
|
||||||
@@ -561,7 +548,6 @@ def _prepare_speaker_metadata(
|
|||||||
roster, applied_languages, updated_config = _apply_speaker_config_to_roster(
|
roster, applied_languages, updated_config = _apply_speaker_config_to_roster(
|
||||||
roster,
|
roster,
|
||||||
speaker_config,
|
speaker_config,
|
||||||
allow_randomize=allow_randomize,
|
|
||||||
persist_changes=persist_config,
|
persist_changes=persist_config,
|
||||||
fallback_languages=global_random_languages,
|
fallback_languages=global_random_languages,
|
||||||
)
|
)
|
||||||
@@ -618,7 +604,6 @@ def _apply_prepare_form(
|
|||||||
str,
|
str,
|
||||||
bool,
|
bool,
|
||||||
bool,
|
bool,
|
||||||
bool,
|
|
||||||
]:
|
]:
|
||||||
raw_chunk_level = (form.get("chunk_level") or pending.chunk_level or "paragraph").strip().lower()
|
raw_chunk_level = (form.get("chunk_level") or pending.chunk_level or "paragraph").strip().lower()
|
||||||
if raw_chunk_level not in _CHUNK_LEVEL_VALUES:
|
if raw_chunk_level not in _CHUNK_LEVEL_VALUES:
|
||||||
@@ -665,11 +650,7 @@ def _apply_prepare_form(
|
|||||||
pending.speakers["narrator"] = existing_narrator
|
pending.speakers["narrator"] = existing_narrator
|
||||||
|
|
||||||
selected_config = (form.get("applied_speaker_config") or "").strip()
|
selected_config = (form.get("applied_speaker_config") or "").strip()
|
||||||
randomize_requested = str(form.get("randomize_speaker_config", "")).strip() in {"1", "true", "on"}
|
apply_config_requested = str(form.get("apply_speaker_config", "")).strip() in {"1", "true", "on"}
|
||||||
apply_config_requested = (
|
|
||||||
str(form.get("apply_speaker_config", "")).strip() in {"1", "true", "on"}
|
|
||||||
or randomize_requested
|
|
||||||
)
|
|
||||||
persist_config_requested = str(form.get("save_speaker_config", "")).strip() in {"1", "true", "on"}
|
persist_config_requested = str(form.get("save_speaker_config", "")).strip() in {"1", "true", "on"}
|
||||||
|
|
||||||
pending.applied_speaker_config = selected_config or None
|
pending.applied_speaker_config = selected_config or None
|
||||||
@@ -694,9 +675,6 @@ def _apply_prepare_form(
|
|||||||
payload.pop("voice", None)
|
payload.pop("voice", None)
|
||||||
payload.pop("resolved_voice", None)
|
payload.pop("resolved_voice", None)
|
||||||
|
|
||||||
randomize_flag = form.get(f"speaker-{speaker_id}-randomize") in {"on", "1", "true"}
|
|
||||||
payload["randomize"] = randomize_flag
|
|
||||||
|
|
||||||
lang_key = f"speaker-{speaker_id}-languages"
|
lang_key = f"speaker-{speaker_id}-languages"
|
||||||
languages: List[str] = []
|
languages: List[str] = []
|
||||||
getter = getattr(form, "getlist", None)
|
getter = getattr(form, "getlist", None)
|
||||||
@@ -782,7 +760,6 @@ def _apply_prepare_form(
|
|||||||
errors,
|
errors,
|
||||||
selected_total,
|
selected_total,
|
||||||
selected_config,
|
selected_config,
|
||||||
randomize_requested,
|
|
||||||
apply_config_requested,
|
apply_config_requested,
|
||||||
persist_config_requested,
|
persist_config_requested,
|
||||||
)
|
)
|
||||||
@@ -833,6 +810,9 @@ _SUPPLEMENT_TEXT_KEYWORDS: List[tuple[str, float]] = [
|
|||||||
("also by", 0.9),
|
("also by", 0.9),
|
||||||
("glossary", 0.8),
|
("glossary", 0.8),
|
||||||
("index", 0.8),
|
("index", 0.8),
|
||||||
|
("newsletter", 3.2),
|
||||||
|
("mailing list", 2.6),
|
||||||
|
("sign-up", 2.2),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -1826,14 +1806,6 @@ def enqueue_job() -> ResponseReturnValue:
|
|||||||
custom_formula_raw = request.form.get("voice_formula", "").strip()
|
custom_formula_raw = request.form.get("voice_formula", "").strip()
|
||||||
selected_speaker_config = (request.form.get("speaker_config") or "").strip()
|
selected_speaker_config = (request.form.get("speaker_config") or "").strip()
|
||||||
speaker_config_payload = get_config(selected_speaker_config) if selected_speaker_config else None
|
speaker_config_payload = get_config(selected_speaker_config) if selected_speaker_config else None
|
||||||
config_randomize_default = False
|
|
||||||
if isinstance(speaker_config_payload, Mapping):
|
|
||||||
speakers_map = speaker_config_payload.get("speakers")
|
|
||||||
if isinstance(speakers_map, Mapping):
|
|
||||||
for config_entry in speakers_map.values():
|
|
||||||
if isinstance(config_entry, Mapping) and config_entry.get("randomize"):
|
|
||||||
config_randomize_default = True
|
|
||||||
break
|
|
||||||
|
|
||||||
if profile_selection in {"__standard", ""}:
|
if profile_selection in {"__standard", ""}:
|
||||||
profile_name = ""
|
profile_name = ""
|
||||||
@@ -1907,7 +1879,6 @@ def enqueue_job() -> ResponseReturnValue:
|
|||||||
run_analysis=initial_analysis,
|
run_analysis=initial_analysis,
|
||||||
speaker_config=speaker_config_payload,
|
speaker_config=speaker_config_payload,
|
||||||
apply_config=bool(speaker_config_payload),
|
apply_config=bool(speaker_config_payload),
|
||||||
allow_randomize=config_randomize_default,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
pending = PendingJob(
|
pending = PendingJob(
|
||||||
@@ -1983,7 +1954,6 @@ def analyze_pending_job(pending_id: str) -> ResponseReturnValue:
|
|||||||
errors,
|
errors,
|
||||||
selected_total,
|
selected_total,
|
||||||
selected_config,
|
selected_config,
|
||||||
randomize_requested,
|
|
||||||
apply_config_requested,
|
apply_config_requested,
|
||||||
persist_config_requested,
|
persist_config_requested,
|
||||||
) = _apply_prepare_form(pending, request.form)
|
) = _apply_prepare_form(pending, request.form)
|
||||||
@@ -2036,7 +2006,6 @@ def analyze_pending_job(pending_id: str) -> ResponseReturnValue:
|
|||||||
run_analysis=True,
|
run_analysis=True,
|
||||||
speaker_config=speaker_config_payload,
|
speaker_config=speaker_config_payload,
|
||||||
apply_config=apply_config_requested or bool(speaker_config_payload),
|
apply_config=apply_config_requested or bool(speaker_config_payload),
|
||||||
allow_randomize=randomize_requested,
|
|
||||||
persist_config=persist_config_requested,
|
persist_config=persist_config_requested,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2077,7 +2046,6 @@ def finalize_job(pending_id: str) -> ResponseReturnValue:
|
|||||||
errors,
|
errors,
|
||||||
selected_total,
|
selected_total,
|
||||||
selected_config,
|
selected_config,
|
||||||
randomize_requested,
|
|
||||||
apply_config_requested,
|
apply_config_requested,
|
||||||
persist_config_requested,
|
persist_config_requested,
|
||||||
) = _apply_prepare_form(pending, request.form)
|
) = _apply_prepare_form(pending, request.form)
|
||||||
@@ -2126,7 +2094,6 @@ def finalize_job(pending_id: str) -> ResponseReturnValue:
|
|||||||
run_analysis=analysis_active,
|
run_analysis=analysis_active,
|
||||||
speaker_config=speaker_config_payload,
|
speaker_config=speaker_config_payload,
|
||||||
apply_config=apply_config_requested or bool(speaker_config_payload),
|
apply_config=apply_config_requested or bool(speaker_config_payload),
|
||||||
allow_randomize=randomize_requested,
|
|
||||||
persist_config=persist_config_requested,
|
persist_config=persist_config_requested,
|
||||||
)
|
)
|
||||||
pending.chunks = processed_chunks
|
pending.chunks = processed_chunks
|
||||||
|
|||||||
+130
-91
@@ -18,6 +18,63 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
const languageMap = parseJSONScript("voice-language-map") || {};
|
const languageMap = parseJSONScript("voice-language-map") || {};
|
||||||
const voiceCatalogMap = new Map(voiceCatalog.map((voice) => [voice.id, voice]));
|
const voiceCatalogMap = new Map(voiceCatalog.map((voice) => [voice.id, voice]));
|
||||||
|
|
||||||
|
const formatCustomMixLabel = (formula) => {
|
||||||
|
if (!formula) return "Custom mix";
|
||||||
|
const segments = formula
|
||||||
|
.split("+")
|
||||||
|
.map((segment) => segment.trim())
|
||||||
|
.filter((segment) => segment.length);
|
||||||
|
if (!segments.length) {
|
||||||
|
return "Custom mix";
|
||||||
|
}
|
||||||
|
const parts = segments.map((segment) => {
|
||||||
|
const [voiceIdRaw, weightRaw] = segment.split("*").map((token) => token.trim());
|
||||||
|
const voiceId = voiceIdRaw || "";
|
||||||
|
const voiceMeta = voiceCatalogMap.get(voiceId);
|
||||||
|
const displayName = voiceMeta?.display_name || voiceId || "Voice";
|
||||||
|
const weight = Number.parseFloat(weightRaw || "");
|
||||||
|
if (!Number.isNaN(weight)) {
|
||||||
|
return `${displayName} ${(weight * 100).toFixed(0)}%`;
|
||||||
|
}
|
||||||
|
return displayName;
|
||||||
|
});
|
||||||
|
return parts.join(" + ");
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureCustomMixOption = (select) => {
|
||||||
|
if (!select) return null;
|
||||||
|
let option = select.querySelector('option[data-role="custom-mix-option"]');
|
||||||
|
if (!option) {
|
||||||
|
option = document.createElement("option");
|
||||||
|
option.value = "__custom_mix";
|
||||||
|
option.dataset.role = "custom-mix-option";
|
||||||
|
option.hidden = true;
|
||||||
|
option.disabled = true;
|
||||||
|
option.textContent = "Custom mix";
|
||||||
|
const firstOptGroup = select.querySelector("optgroup");
|
||||||
|
if (firstOptGroup) {
|
||||||
|
select.insertBefore(option, firstOptGroup);
|
||||||
|
} else {
|
||||||
|
select.appendChild(option);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return option;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateCustomMixOption = (select, formula) => {
|
||||||
|
const option = ensureCustomMixOption(select);
|
||||||
|
if (!option) return;
|
||||||
|
if (formula) {
|
||||||
|
option.hidden = false;
|
||||||
|
option.disabled = false;
|
||||||
|
option.textContent = formatCustomMixLabel(formula);
|
||||||
|
} else {
|
||||||
|
option.hidden = true;
|
||||||
|
option.disabled = true;
|
||||||
|
option.textContent = "Custom mix";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const chapterRows = Array.from(form.querySelectorAll("[data-role=chapter-row]"));
|
const chapterRows = Array.from(form.querySelectorAll("[data-role=chapter-row]"));
|
||||||
|
|
||||||
const updateRowState = (row) => {
|
const updateRowState = (row) => {
|
||||||
@@ -87,99 +144,91 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
const updatePreviewVoice = (select) => {
|
const updatePreviewVoice = (select) => {
|
||||||
const container = select.closest(".speaker-list__item");
|
const container = select.closest(".speaker-list__item");
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
const previewButton = container.querySelector('[data-role="speaker-preview"]');
|
const previewButtons = container.querySelectorAll('[data-role="speaker-preview"]');
|
||||||
if (!previewButton) return;
|
if (!previewButtons.length) return;
|
||||||
|
|
||||||
const formulaInput = container.querySelector('[data-role="speaker-formula"]');
|
const formulaInput = container.querySelector('[data-role="speaker-formula"]');
|
||||||
const mixContainer = container.querySelector('[data-role="speaker-mix"]');
|
const mixContainer = container.querySelector('[data-role="speaker-mix"]');
|
||||||
const mixLabel = container.querySelector('[data-role="speaker-mix-label"]');
|
const mixLabel = container.querySelector('[data-role="speaker-mix-label"]');
|
||||||
const formulaValue = formulaInput?.value?.trim();
|
|
||||||
|
const formulaValue = formulaInput?.value?.trim() || "";
|
||||||
|
updateCustomMixOption(select, formulaValue);
|
||||||
|
|
||||||
|
const defaultVoice = select.dataset.defaultVoice || "";
|
||||||
|
let assignedVoice = select.value || defaultVoice;
|
||||||
|
if (select.value === "__custom_mix" || formulaValue) {
|
||||||
|
assignedVoice = formulaValue || defaultVoice;
|
||||||
|
}
|
||||||
|
|
||||||
if (formulaValue) {
|
if (formulaValue) {
|
||||||
previewButton.dataset.voice = formulaValue;
|
if (mixContainer) mixContainer.hidden = false;
|
||||||
if (mixContainer) {
|
if (mixLabel) mixLabel.textContent = formulaValue;
|
||||||
mixContainer.hidden = false;
|
} else {
|
||||||
|
if (mixContainer) mixContainer.hidden = true;
|
||||||
|
if (mixLabel) mixLabel.textContent = "";
|
||||||
|
if (assignedVoice === "__custom_mix" || !assignedVoice) {
|
||||||
|
assignedVoice = defaultVoice;
|
||||||
}
|
}
|
||||||
if (mixLabel) {
|
|
||||||
mixLabel.textContent = formulaValue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
previewButtons.forEach((button) => {
|
||||||
|
const kind = button.dataset.previewKind || "";
|
||||||
|
if (kind === "generated") {
|
||||||
|
button.hidden = !formulaValue;
|
||||||
|
button.dataset.voice = assignedVoice;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (mixContainer) {
|
|
||||||
mixContainer.hidden = true;
|
|
||||||
}
|
|
||||||
const defaultVoice = select.dataset.defaultVoice || previewButton.dataset.voice || "";
|
|
||||||
const currentVoice = select.disabled ? defaultVoice : (select.value || defaultVoice);
|
|
||||||
previewButton.dataset.voice = currentVoice || defaultVoice;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRandomizeToggle = (checkbox) => {
|
const context = button.dataset.previewContext || "";
|
||||||
const container = checkbox.closest(".speaker-list__item");
|
if (context === "mix") {
|
||||||
if (!container) return;
|
button.dataset.voice = formulaValue || assignedVoice;
|
||||||
const select = container.querySelector('[data-role="speaker-voice"]');
|
return;
|
||||||
if (!select) return;
|
|
||||||
if (checkbox.checked) {
|
|
||||||
if (!select.dataset.prevManual) {
|
|
||||||
select.dataset.prevManual = select.value;
|
|
||||||
}
|
|
||||||
const formulaInput = container.querySelector('[data-role="speaker-formula"]');
|
|
||||||
const mixContainer = container.querySelector('[data-role="speaker-mix"]');
|
|
||||||
if (formulaInput) {
|
|
||||||
formulaInput.value = "";
|
|
||||||
}
|
|
||||||
if (mixContainer) {
|
|
||||||
mixContainer.hidden = true;
|
|
||||||
}
|
|
||||||
select.dataset.suppressRandomize = "1";
|
|
||||||
select.disabled = true;
|
|
||||||
select.value = "";
|
|
||||||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
|
||||||
delete select.dataset.suppressRandomize;
|
|
||||||
} else {
|
|
||||||
const previous = select.dataset.prevManual || "";
|
|
||||||
select.disabled = false;
|
|
||||||
select.dataset.suppressRandomize = "1";
|
|
||||||
select.value = previous;
|
|
||||||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
|
||||||
delete select.dataset.suppressRandomize;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button.dataset.voice = assignedVoice || defaultVoice || button.dataset.voice || "";
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const voiceSelects = Array.from(form.querySelectorAll('[data-role="speaker-voice"]'));
|
const voiceSelects = Array.from(form.querySelectorAll('[data-role="speaker-voice"]'));
|
||||||
voiceSelects.forEach((select) => {
|
voiceSelects.forEach((select) => {
|
||||||
|
ensureCustomMixOption(select);
|
||||||
select.addEventListener("change", (event) => {
|
select.addEventListener("change", (event) => {
|
||||||
const target = event.target;
|
const target = event.target;
|
||||||
const container = target.closest(".speaker-list__item");
|
const container = target.closest(".speaker-list__item");
|
||||||
if (container && !target.dataset.suppressRandomize) {
|
if (!container) return;
|
||||||
const randomToggle = container.querySelector('[data-role="randomize-toggle"]');
|
|
||||||
if (randomToggle && randomToggle.checked && target.value) {
|
|
||||||
randomToggle.checked = false;
|
|
||||||
handleRandomizeToggle(randomToggle);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (container && !target.dataset.suppressFormulaClear) {
|
|
||||||
const formulaInput = container.querySelector('[data-role="speaker-formula"]');
|
const formulaInput = container.querySelector('[data-role="speaker-formula"]');
|
||||||
const mixContainer = container.querySelector('[data-role="speaker-mix"]');
|
const mixContainer = container.querySelector('[data-role="speaker-mix"]');
|
||||||
|
const mixLabel = container.querySelector('[data-role="speaker-mix-label"]');
|
||||||
|
|
||||||
|
if (target.value === "__custom_mix") {
|
||||||
|
if (!formulaInput?.value?.trim()) {
|
||||||
|
const previous = target.dataset.prevManual || "";
|
||||||
|
target.value = previous;
|
||||||
|
}
|
||||||
|
updatePreviewVoice(target);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!target.dataset.suppressFormulaClear) {
|
||||||
if (formulaInput) {
|
if (formulaInput) {
|
||||||
formulaInput.value = "";
|
formulaInput.value = "";
|
||||||
}
|
}
|
||||||
|
if (mixLabel) {
|
||||||
|
mixLabel.textContent = "";
|
||||||
|
}
|
||||||
if (mixContainer) {
|
if (mixContainer) {
|
||||||
mixContainer.hidden = true;
|
mixContainer.hidden = true;
|
||||||
}
|
}
|
||||||
|
updateCustomMixOption(target, "");
|
||||||
}
|
}
|
||||||
if (!target.dataset.suppressRandomize) {
|
|
||||||
target.dataset.prevManual = target.value || "";
|
target.dataset.prevManual = target.value || "";
|
||||||
}
|
|
||||||
updatePreviewVoice(target);
|
updatePreviewVoice(target);
|
||||||
delete target.dataset.suppressFormulaClear;
|
delete target.dataset.suppressFormulaClear;
|
||||||
});
|
});
|
||||||
updatePreviewVoice(select);
|
updatePreviewVoice(select);
|
||||||
});
|
});
|
||||||
|
|
||||||
const randomizeToggles = Array.from(form.querySelectorAll('[data-role="randomize-toggle"]'));
|
|
||||||
randomizeToggles.forEach((checkbox) => {
|
|
||||||
handleRandomizeToggle(checkbox);
|
|
||||||
checkbox.addEventListener("change", () => handleRandomizeToggle(checkbox));
|
|
||||||
});
|
|
||||||
|
|
||||||
const finalizeAction = form.getAttribute("action");
|
const finalizeAction = form.getAttribute("action");
|
||||||
const analyzeUrl = form.dataset.analyzeUrl || "";
|
const analyzeUrl = form.dataset.analyzeUrl || "";
|
||||||
const activeStepInput = form.querySelector('[data-role="active-step-input"]');
|
const activeStepInput = form.querySelector('[data-role="active-step-input"]');
|
||||||
@@ -427,12 +476,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
const formulaInput = speakerItem.querySelector('[data-role="speaker-formula"]');
|
const formulaInput = speakerItem.querySelector('[data-role="speaker-formula"]');
|
||||||
const mixLabel = speakerItem.querySelector('[data-role="speaker-mix-label"]');
|
const mixLabel = speakerItem.querySelector('[data-role="speaker-mix-label"]');
|
||||||
const mixContainer = speakerItem.querySelector('[data-role="speaker-mix"]');
|
const mixContainer = speakerItem.querySelector('[data-role="speaker-mix"]');
|
||||||
const previewButton = speakerItem.querySelector('[data-role="speaker-preview"]');
|
|
||||||
const randomToggle = speakerItem.querySelector('[data-role="randomize-toggle"]');
|
|
||||||
if (randomToggle && randomToggle.checked) {
|
|
||||||
randomToggle.checked = false;
|
|
||||||
handleRandomizeToggle(randomToggle);
|
|
||||||
}
|
|
||||||
if (formulaInput) {
|
if (formulaInput) {
|
||||||
formulaInput.value = formula || "";
|
formulaInput.value = formula || "";
|
||||||
}
|
}
|
||||||
@@ -442,26 +486,29 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
if (mixContainer) {
|
if (mixContainer) {
|
||||||
mixContainer.hidden = !formula;
|
mixContainer.hidden = !formula;
|
||||||
}
|
}
|
||||||
if (select) {
|
|
||||||
select.disabled = false;
|
if (!select) {
|
||||||
select.dataset.suppressRandomize = "1";
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureCustomMixOption(select);
|
||||||
|
|
||||||
|
if (formula) {
|
||||||
|
if (select.value !== "__custom_mix") {
|
||||||
|
select.dataset.prevManual = select.value || select.dataset.prevManual || select.dataset.defaultVoice || "";
|
||||||
|
}
|
||||||
select.dataset.suppressFormulaClear = "1";
|
select.dataset.suppressFormulaClear = "1";
|
||||||
if (formula) {
|
updateCustomMixOption(select, formula);
|
||||||
select.value = "";
|
select.value = "__custom_mix";
|
||||||
} else if (!select.value && select.dataset.prevManual) {
|
|
||||||
select.value = select.dataset.prevManual;
|
|
||||||
}
|
|
||||||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
|
||||||
delete select.dataset.suppressRandomize;
|
|
||||||
}
|
|
||||||
if (previewButton) {
|
|
||||||
if (formula) {
|
|
||||||
previewButton.dataset.voice = formula;
|
|
||||||
} else {
|
} else {
|
||||||
const defaultVoice = select?.dataset.defaultVoice || previewButton.dataset.voice || "";
|
const fallback = select.dataset.prevManual || select.dataset.defaultVoice || "";
|
||||||
previewButton.dataset.voice = select?.value || defaultVoice;
|
select.dataset.suppressFormulaClear = "1";
|
||||||
}
|
updateCustomMixOption(select, "");
|
||||||
|
select.value = fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updatePreviewVoice(select);
|
||||||
|
delete select.dataset.suppressFormulaClear;
|
||||||
};
|
};
|
||||||
|
|
||||||
const hideGenderMenus = () => {
|
const hideGenderMenus = () => {
|
||||||
@@ -999,16 +1046,8 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
if (!container) return;
|
if (!container) return;
|
||||||
const select = container.querySelector('[data-role="speaker-voice"]');
|
const select = container.querySelector('[data-role="speaker-voice"]');
|
||||||
if (!select) return;
|
if (!select) return;
|
||||||
const randomToggle = container.querySelector('[data-role="randomize-toggle"]');
|
|
||||||
if (randomToggle && randomToggle.checked) {
|
|
||||||
randomToggle.checked = false;
|
|
||||||
handleRandomizeToggle(randomToggle);
|
|
||||||
}
|
|
||||||
select.disabled = false;
|
|
||||||
select.dataset.suppressRandomize = "1";
|
|
||||||
select.value = chip.dataset.voice || "";
|
select.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 || "";
|
select.dataset.prevManual = select.value || "";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1231,6 +1231,62 @@ body {
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
margin-bottom: 0.3rem;
|
margin-bottom: 0.3rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speaker-gender {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speaker-gender__pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.32rem 0.8rem;
|
||||||
|
min-width: 8.5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(56, 189, 248, 0.35);
|
||||||
|
background: rgba(56, 189, 248, 0.12);
|
||||||
|
color: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speaker-gender__pill:hover,
|
||||||
|
.speaker-gender__pill.is-open {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.speaker-gender__menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 0.45rem);
|
||||||
|
left: 0;
|
||||||
|
z-index: 40;
|
||||||
|
min-width: 220px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid var(--panel-border);
|
||||||
|
background: rgba(15, 23, 42, 0.96);
|
||||||
|
box-shadow: 0 18px 40px rgba(7, 14, 32, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.speaker-gender__menu[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speaker-gender__options {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.speaker-gender__options .chip {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.speaker-list__controls {
|
.speaker-list__controls {
|
||||||
@@ -1289,21 +1345,21 @@ body {
|
|||||||
|
|
||||||
.prepare-form {
|
.prepare-form {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1.5rem;
|
gap: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chapter-grid {
|
.chapter-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1rem;
|
gap: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chapter-card {
|
.chapter-card {
|
||||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||||
border-radius: 20px;
|
border-radius: 22px;
|
||||||
padding: 1rem 1.25rem;
|
padding: 1.25rem 1.5rem;
|
||||||
background: rgba(15, 23, 42, 0.32);
|
background: rgba(15, 23, 42, 0.32);
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.85rem;
|
gap: 1.1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chapter-card[data-disabled="true"] {
|
.chapter-card[data-disabled="true"] {
|
||||||
@@ -1314,7 +1370,7 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 1rem;
|
gap: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chapter-card__title {
|
.chapter-card__title {
|
||||||
@@ -1517,17 +1573,20 @@ body {
|
|||||||
|
|
||||||
.prepare-actions {
|
.prepare-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: flex-end;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
|
padding-top: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prepare-actions .button {
|
.prepare-actions .button {
|
||||||
min-width: 190px;
|
min-width: 200px;
|
||||||
|
padding-left: 1.75rem;
|
||||||
|
padding-right: 1.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prepare-actions .button--ghost {
|
.prepare-actions .button--ghost {
|
||||||
border-color: rgba(148, 163, 184, 0.25);
|
border-color: rgba(148, 163, 184, 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle-pill input:checked + span {
|
.toggle-pill input:checked + span {
|
||||||
|
|||||||
@@ -43,16 +43,6 @@
|
|||||||
<div class="alert alert--info">{{ notice }}</div>
|
<div class="alert alert--info">{{ notice }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="prepare-step-nav" data-role="prepare-step-nav">
|
|
||||||
<button type="button" class="button button--ghost{% if is_chapters %} is-active{% endif %}" data-step-target="chapters">Step 2 · Chapters</button>
|
|
||||||
<button type="button"
|
|
||||||
class="button button--ghost{% if is_speakers %} is-active{% endif %}"
|
|
||||||
data-step-target="speakers"
|
|
||||||
{% if not is_speakers %}disabled aria-disabled="true" data-state="locked"{% else %}data-state="unlocked"{% endif %}>
|
|
||||||
Step 3 · Speakers
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="prepare-steps" data-role="prepare-step-panels">
|
<div class="prepare-steps" data-role="prepare-step-panels">
|
||||||
<section class="prepare-step" data-step-panel="chapters">
|
<section class="prepare-step" data-step-panel="chapters">
|
||||||
<header class="prepare-step__header">
|
<header class="prepare-step__header">
|
||||||
@@ -198,16 +188,6 @@
|
|||||||
{% if not speaker_configs %}disabled{% endif %}>
|
{% if not speaker_configs %}disabled{% endif %}>
|
||||||
Apply preset
|
Apply preset
|
||||||
</button>
|
</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">
|
<label class="toggle-pill">
|
||||||
<input type="checkbox" name="save_speaker_config" value="1">
|
<input type="checkbox" name="save_speaker_config" value="1">
|
||||||
<span>Save roster updates back to preset</span>
|
<span>Save roster updates back to preset</span>
|
||||||
@@ -274,9 +254,6 @@
|
|||||||
{% if speaker.get('analysis_count') %}
|
{% if speaker.get('analysis_count') %}
|
||||||
<span class="badge badge--muted">{{ speaker.analysis_count }} lines · {{ speaker.analysis_confidence|default('low')|title }} confidence</span>
|
<span class="badge badge--muted">{{ speaker.analysis_count }} lines · {{ speaker.analysis_confidence|default('low')|title }} confidence</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if settings.speaker_random_languages %}
|
|
||||||
<span class="badge badge--muted">Randomizer: {{ settings.speaker_random_languages | map('upper') | join(', ') }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
<label class="speaker-list__field" for="speaker-{{ speaker_id }}-pronunciation">
|
<label class="speaker-list__field" for="speaker-{{ speaker_id }}-pronunciation">
|
||||||
<span>Pronunciation</span>
|
<span>Pronunciation</span>
|
||||||
@@ -293,9 +270,11 @@
|
|||||||
<select id="speaker-{{ speaker_id }}-voice"
|
<select id="speaker-{{ speaker_id }}-voice"
|
||||||
name="speaker-{{ speaker_id }}-voice"
|
name="speaker-{{ speaker_id }}-voice"
|
||||||
data-role="speaker-voice"
|
data-role="speaker-voice"
|
||||||
data-default-voice="{{ pending.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>
|
<option value="" {% if not selected_voice %}selected{% endif %}>Use narrator voice ({{ pending.voice }})</option>
|
||||||
|
<option value="__custom_mix" data-role="custom-mix-option" {% if speaker.voice_formula %}selected{% else %}hidden disabled{% endif %}>
|
||||||
|
Custom mix
|
||||||
|
</option>
|
||||||
{% if speaker.recommended_voices %}
|
{% if speaker.recommended_voices %}
|
||||||
<optgroup label="Recommended">
|
<optgroup label="Recommended">
|
||||||
{% for voice_id in speaker.recommended_voices[:6] %}
|
{% for voice_id in speaker.recommended_voices[:6] %}
|
||||||
@@ -333,22 +312,39 @@
|
|||||||
data-speaker-id="{{ speaker_id }}">
|
data-speaker-id="{{ speaker_id }}">
|
||||||
Generate voice
|
Generate voice
|
||||||
</button>
|
</button>
|
||||||
</div>
|
<button type="button"
|
||||||
<div class="speaker-list__inline">
|
class="button button--ghost button--small"
|
||||||
<label class="toggle-pill">
|
data-role="speaker-preview"
|
||||||
<input type="checkbox"
|
data-preview-kind="generated"
|
||||||
name="speaker-{{ speaker_id }}-randomize"
|
data-speaker-id="{{ speaker_id }}"
|
||||||
value="1"
|
data-preview-text="{{ preview_text|e }}"
|
||||||
data-role="randomize-toggle"
|
data-language="{{ pending.language }}"
|
||||||
{% if speaker.randomize %}checked{% endif %}>
|
data-voice="{{ speaker.voice_formula or selected_voice or pending.voice }}"
|
||||||
<span>Randomize compatible voice on analyze</span>
|
data-speed="{{ '%.2f'|format(pending.speed) }}"
|
||||||
</label>
|
data-use-gpu="{{ 'true' if pending.use_gpu else 'false' }}"
|
||||||
|
{% if not speaker.voice_formula %}hidden{% endif %}>
|
||||||
|
Preview generated
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="speaker-list__mix" data-role="speaker-mix" {% if not speaker.voice_formula %}hidden{% endif %}>
|
<div class="speaker-list__mix" data-role="speaker-mix" {% if not speaker.voice_formula %}hidden{% endif %}>
|
||||||
<span class="tag">Custom mix</span>
|
<span class="tag">Custom mix</span>
|
||||||
<span data-role="speaker-mix-label">{{ speaker.voice_formula or '' }}</span>
|
<span data-role="speaker-mix-label">{{ speaker.voice_formula or '' }}</span>
|
||||||
|
<div class="speaker-list__mix-actions">
|
||||||
|
<button type="button"
|
||||||
|
class="button button--ghost button--small"
|
||||||
|
data-role="speaker-preview"
|
||||||
|
data-preview-context="mix"
|
||||||
|
data-speaker-id="{{ speaker_id }}"
|
||||||
|
data-preview-text="{{ preview_text|e }}"
|
||||||
|
data-language="{{ pending.language }}"
|
||||||
|
data-voice="{{ speaker.voice_formula or selected_voice or pending.voice }}"
|
||||||
|
data-speed="{{ '%.2f'|format(pending.speed) }}"
|
||||||
|
data-use-gpu="{{ 'true' if pending.use_gpu else 'false' }}">
|
||||||
|
Preview mix
|
||||||
|
</button>
|
||||||
<button type="button" class="button button--ghost button--small" data-role="clear-mix">Clear</button>
|
<button type="button" class="button button--ghost button--small" data-role="clear-mix">Clear</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<input type="hidden" name="speaker-{{ speaker_id }}-formula" value="{{ speaker.voice_formula or '' }}" data-role="speaker-formula">
|
<input type="hidden" name="speaker-{{ speaker_id }}-formula" value="{{ speaker.voice_formula or '' }}" data-role="speaker-formula">
|
||||||
</div>
|
</div>
|
||||||
<details class="speaker-list__samples" {% if not sample_quotes %}data-state="empty"{% endif %}>
|
<details class="speaker-list__samples" {% if not sample_quotes %}data-state="empty"{% endif %}>
|
||||||
@@ -447,7 +443,6 @@
|
|||||||
<button type="button" class="button button--ghost button--small" data-role="voice-modal-gender" data-value="f">Female</button>
|
<button type="button" class="button button--ghost button--small" data-role="voice-modal-gender" data-value="f">Female</button>
|
||||||
<button type="button" class="button button--ghost button--small" data-role="voice-modal-gender" data-value="m">Male</button>
|
<button type="button" class="button button--ghost button--small" data-role="voice-modal-gender" data-value="m">Male</button>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="button button--ghost" data-role="voice-modal-random">Random blend</button>
|
|
||||||
<button type="button" class="button button--ghost" data-role="voice-modal-clear">Clear selection</button>
|
<button type="button" class="button button--ghost" data-role="voice-modal-clear">Clear selection</button>
|
||||||
</aside>
|
</aside>
|
||||||
<section class="voice-browser__catalog" aria-label="Voice catalog">
|
<section class="voice-browser__catalog" aria-label="Voice catalog">
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
{% macro speaker_row(row_key, speaker, options) %}
|
{% macro speaker_row(row_key, speaker, options) %}
|
||||||
{% set gender = (speaker.gender or 'unknown') %}
|
{% set gender = (speaker.gender or 'unknown') %}
|
||||||
{% 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 }}">
|
||||||
<input type="hidden" name="speaker_rows" value="{{ row_key }}">
|
<input type="hidden" name="speaker_rows" value="{{ row_key }}">
|
||||||
<input type="hidden" name="speaker-{{ row_key }}-id" value="{{ speaker.id or row_key }}">
|
<input type="hidden" name="speaker-{{ row_key }}-id" value="{{ speaker.id or row_key }}">
|
||||||
@@ -30,10 +29,6 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="speaker-config-row__footer">
|
<div class="speaker-config-row__footer">
|
||||||
<label class="toggle-pill">
|
|
||||||
<input type="checkbox" name="speaker-{{ row_key }}-randomize" value="1" {% if randomize %}checked{% endif %}>
|
|
||||||
<span>Randomize compatible voice when applied</span>
|
|
||||||
</label>
|
|
||||||
<button type="button" class="button button--ghost button--small" data-action="remove-speaker">
|
<button type="button" class="button button--ghost button--small" data-action="remove-speaker">
|
||||||
Remove
|
Remove
|
||||||
</button>
|
</button>
|
||||||
@@ -60,7 +55,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card__title">Speaker presets</div>
|
<div class="card__title">Speaker presets</div>
|
||||||
<p class="card__subtitle">Store recurring casts, control randomization defaults, and keep voices consistent between books.</p>
|
<p class="card__subtitle">Store recurring casts and keep voices consistent between books.</p>
|
||||||
|
|
||||||
{% if message %}
|
{% if message %}
|
||||||
<div class="alert alert--success">{{ message }}</div>
|
<div class="alert alert--success">{{ message }}</div>
|
||||||
@@ -125,7 +120,7 @@
|
|||||||
<h3>Speakers</h3>
|
<h3>Speakers</h3>
|
||||||
<button type="button" class="button button--ghost button--small" data-action="add-speaker">Add speaker</button>
|
<button type="button" class="button button--ghost button--small" data-action="add-speaker">Add speaker</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="hint">Add each recurring character, set their gender, and decide whether to randomize a compatible voice automatically.</p>
|
<p class="hint">Add each recurring character, set their gender, and choose a preferred voice.</p>
|
||||||
<div class="speaker-config-rows__list" data-role="speaker-rows">
|
<div class="speaker-config-rows__list" data-role="speaker-rows">
|
||||||
{% set speakers_map = editing.speakers or {} %}
|
{% set speakers_map = editing.speakers or {} %}
|
||||||
{% if speakers_map %}
|
{% if speakers_map %}
|
||||||
@@ -147,7 +142,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template id="speaker-row-template">
|
<template id="speaker-row-template">
|
||||||
{{ speaker_row('__ROW__', {'id': '', 'label': '', 'gender': 'unknown', 'voice': '', 'randomize': True}, options) | safe }}
|
{{ speaker_row('__ROW__', {'id': '', 'label': '', 'gender': 'unknown', 'voice': ''}, options) | safe }}
|
||||||
</template>
|
</template>
|
||||||
</section>
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user