feat: Implement pronunciation store with SQLite backend

- Added a new module for managing pronunciation overrides using SQLite.
- Implemented functions to load, save, search, and delete pronunciation overrides.
- Introduced schema for storing overrides and metadata.
- Added thread-safe access to the database with RLock.
- Created a utility for normalizing tokens for consistent storage and retrieval.

refactor: Overhaul entities step in the preparation wizard

- Renamed Step 3 from "Speakers" to "Entities" across all templates and routes.
- Introduced sub-navigation with tabs for "People", "Entities", and "Manual Overrides".
- Enhanced UI to display detected entities and allow manual overrides for pronunciations.
- Implemented search functionality for manual overrides with AJAX support.
- Updated frontend logic to manage tab interactions and voice selections.

docs: Add detailed plan for entities step overhaul

- Documented requirements, implementation strategies, and testing plans for the entities step.
- Outlined the integration of POS tagging and entity recognition using spaCy.
- Provided a comprehensive overview of the manual overrides workflow and data persistence strategies.
This commit is contained in:
JB
2025-10-11 14:14:19 -07:00
parent 610091c0d9
commit 93fe48f601
13 changed files with 2440 additions and 300 deletions
+430 -25
View File
@@ -50,6 +50,18 @@ from abogen.utils import (
load_numpy_kpipeline,
save_config,
)
from abogen.entity_analysis import (
extract_entities,
merge_override,
normalize_token as normalize_entity_token,
search_tokens as search_entity_tokens,
)
from abogen.pronunciation_store import (
delete_override as delete_pronunciation_override,
load_overrides as load_pronunciation_overrides,
save_override as save_pronunciation_override,
search_overrides as search_pronunciation_overrides,
)
from abogen.voice_profiles import (
delete_profile,
duplicate_profile,
@@ -958,6 +970,318 @@ def _prepare_speaker_metadata(
return chunk_list, roster, analysis_payload, applied_languages, updated_config
def _collect_pronunciation_overrides(pending: PendingJob) -> List[Dict[str, Any]]:
language = pending.language or "en"
collected: Dict[str, Dict[str, Any]] = {}
summary = pending.entity_summary or {}
for group in ("people", "entities"):
entries = summary.get(group)
if not isinstance(entries, list):
continue
for entry in entries:
if not isinstance(entry, Mapping):
continue
override_payload = entry.get("override")
if not isinstance(override_payload, Mapping):
continue
token_value = str(entry.get("label") or override_payload.get("token") or "").strip()
pronunciation_value = str(override_payload.get("pronunciation") or "").strip()
if not token_value or not pronunciation_value:
continue
normalized = normalize_entity_token(entry.get("normalized") or token_value)
if not normalized:
continue
collected[normalized] = {
"token": token_value,
"normalized": normalized,
"pronunciation": pronunciation_value,
"voice": str(override_payload.get("voice") or "").strip() or None,
"notes": str(override_payload.get("notes") or "").strip() or None,
"context": str(override_payload.get("context") or "").strip() or None,
"source": f"{group}-override",
"language": language,
}
if isinstance(pending.speakers, Mapping):
for speaker_payload in pending.speakers.values():
if not isinstance(speaker_payload, Mapping):
continue
token_value = str(speaker_payload.get("label") or "").strip()
pronunciation_value = str(speaker_payload.get("pronunciation") or "").strip()
if not token_value or not pronunciation_value:
continue
normalized = normalize_entity_token(token_value)
if not normalized:
continue
collected[normalized] = {
"token": token_value,
"normalized": normalized,
"pronunciation": pronunciation_value,
"voice": str(
speaker_payload.get("resolved_voice")
or speaker_payload.get("voice")
or pending.voice
).strip()
or None,
"notes": None,
"context": None,
"source": "speaker",
"language": language,
}
for manual_entry in pending.manual_overrides or []:
if not isinstance(manual_entry, Mapping):
continue
token_value = str(manual_entry.get("token") or "").strip()
pronunciation_value = str(manual_entry.get("pronunciation") or "").strip()
if not token_value or not pronunciation_value:
continue
normalized = manual_entry.get("normalized") or normalize_entity_token(token_value)
if not normalized:
continue
collected[normalized] = {
"token": token_value,
"normalized": normalized,
"pronunciation": pronunciation_value,
"voice": str(manual_entry.get("voice") or "").strip() or None,
"notes": str(manual_entry.get("notes") or "").strip() or None,
"context": str(manual_entry.get("context") or "").strip() or None,
"source": str(manual_entry.get("source") or "manual"),
"language": language,
}
return list(collected.values())
def _sync_pronunciation_overrides(pending: PendingJob) -> None:
pending.pronunciation_overrides = _collect_pronunciation_overrides(pending)
if not pending.pronunciation_overrides:
return
summary = pending.entity_summary or {}
manual_map: Dict[str, Mapping[str, Any]] = {}
for override in pending.manual_overrides or []:
if not isinstance(override, Mapping):
continue
normalized = override.get("normalized") or normalize_entity_token(override.get("token") or "")
pronunciation_value = str(override.get("pronunciation") or "").strip()
if not normalized or not pronunciation_value:
continue
manual_map[normalized] = override
for group in ("people", "entities"):
entries = summary.get(group)
if not isinstance(entries, list):
continue
for entry in entries:
if not isinstance(entry, dict):
continue
normalized = normalize_entity_token(entry.get("normalized") or entry.get("label") or "")
manual_override = manual_map.get(normalized)
if manual_override:
entry["override"] = {
"token": manual_override.get("token"),
"pronunciation": manual_override.get("pronunciation"),
"voice": manual_override.get("voice"),
"notes": manual_override.get("notes"),
"context": manual_override.get("context"),
"source": manual_override.get("source"),
}
def _refresh_entity_summary(pending: PendingJob, chapters: Iterable[Mapping[str, Any]]) -> None:
language = pending.language or "en"
result = extract_entities(chapters, language=language)
summary = dict(result.summary)
tokens: List[str] = []
for group in ("people", "entities"):
entries = summary.get(group)
if not isinstance(entries, list):
continue
for entry in entries:
if not isinstance(entry, Mapping):
continue
token_value = str(entry.get("normalized") or entry.get("label") or "").strip()
if token_value:
tokens.append(token_value)
overrides_from_store = load_pronunciation_overrides(language=language, tokens=tokens)
merged_summary = merge_override(summary, overrides_from_store)
if result.errors:
merged_summary["errors"] = list(result.errors)
merged_summary["cache_key"] = result.cache_key
pending.entity_summary = merged_summary
pending.entity_cache_key = result.cache_key
_sync_pronunciation_overrides(pending)
def _find_manual_override(pending: PendingJob, identifier: str) -> Optional[Dict[str, Any]]:
for entry in pending.manual_overrides or []:
if not isinstance(entry, dict):
continue
if entry.get("id") == identifier or entry.get("normalized") == identifier:
return entry
return None
def _upsert_manual_override(pending: PendingJob, payload: Mapping[str, Any]) -> Dict[str, Any]:
token_value = str(payload.get("token") or "").strip()
if not token_value:
raise ValueError("Token is required")
pronunciation_value = str(payload.get("pronunciation") or "").strip()
voice_value = str(payload.get("voice") or "").strip()
notes_value = str(payload.get("notes") or "").strip()
context_value = str(payload.get("context") or "").strip()
normalized = payload.get("normalized") or normalize_entity_token(token_value)
if not normalized:
raise ValueError("Token is required")
existing = _find_manual_override(pending, payload.get("id", "")) or _find_manual_override(pending, normalized)
timestamp = time.time()
language = pending.language or "en"
if existing:
existing.update(
{
"token": token_value,
"normalized": normalized,
"pronunciation": pronunciation_value,
"voice": voice_value,
"notes": notes_value,
"context": context_value,
"updated_at": timestamp,
}
)
manual_entry = existing
else:
manual_entry = {
"id": payload.get("id") or uuid.uuid4().hex,
"token": token_value,
"normalized": normalized,
"pronunciation": pronunciation_value,
"voice": voice_value,
"notes": notes_value,
"context": context_value,
"language": language,
"source": payload.get("source") or "manual",
"created_at": timestamp,
"updated_at": timestamp,
}
if isinstance(pending.manual_overrides, list):
pending.manual_overrides.append(manual_entry)
else:
pending.manual_overrides = [manual_entry]
save_pronunciation_override(
language=language,
token=token_value,
pronunciation=pronunciation_value or None,
voice=voice_value or None,
notes=notes_value or None,
context=context_value or None,
)
_sync_pronunciation_overrides(pending)
return dict(manual_entry)
def _delete_manual_override(pending: PendingJob, override_id: str) -> bool:
if not override_id:
return False
entries = pending.manual_overrides or []
for index, entry in enumerate(entries):
if not isinstance(entry, dict):
continue
if entry.get("id") == override_id:
token_value = entry.get("token") or ""
language = pending.language or "en"
delete_pronunciation_override(language=language, token=token_value)
entries.pop(index)
pending.manual_overrides = entries
_sync_pronunciation_overrides(pending)
return True
return False
def _search_manual_override_candidates(pending: PendingJob, query: str, *, limit: int = 15) -> List[Dict[str, Any]]:
normalized_query = (query or "").strip()
summary_index = (pending.entity_summary or {}).get("index", {})
matches = search_entity_tokens(summary_index, normalized_query, limit=limit)
registry: Dict[str, Dict[str, Any]] = {}
for entry in matches:
normalized = normalize_entity_token(entry.get("normalized") or entry.get("token") or "")
if not normalized:
continue
registry.setdefault(
normalized,
{
"token": entry.get("token"),
"normalized": normalized,
"category": entry.get("category") or "entity",
"count": entry.get("count", 0),
"samples": entry.get("samples", []),
"source": "entity",
},
)
language = pending.language or "en"
store_matches = search_pronunciation_overrides(language=language, query=normalized_query, limit=limit)
for entry in store_matches:
normalized = entry.get("normalized")
if not normalized:
continue
registry.setdefault(
normalized,
{
"token": entry.get("token"),
"normalized": normalized,
"category": "history",
"count": entry.get("usage_count", 0),
"samples": [entry.get("context")] if entry.get("context") else [],
"source": "history",
"pronunciation": entry.get("pronunciation"),
"voice": entry.get("voice"),
},
)
for entry in pending.manual_overrides or []:
if not isinstance(entry, Mapping):
continue
normalized = entry.get("normalized")
if not normalized:
continue
registry.setdefault(
normalized,
{
"token": entry.get("token"),
"normalized": normalized,
"category": "manual",
"count": 0,
"samples": [entry.get("context")] if entry.get("context") else [],
"source": "manual",
"pronunciation": entry.get("pronunciation"),
"voice": entry.get("voice"),
},
)
ordered = sorted(registry.values(), key=lambda item: (-int(item.get("count") or 0), item.get("token") or ""))
if limit:
return ordered[:limit]
return ordered
def _pending_entities_payload(pending: PendingJob) -> Dict[str, Any]:
return {
"summary": pending.entity_summary or {},
"manual_overrides": pending.manual_overrides or [],
"pronunciation_overrides": pending.pronunciation_overrides or [],
"cache_key": pending.entity_cache_key,
"language": pending.language or "en",
}
def _apply_prepare_form(
pending: PendingJob, form: Mapping[str, Any]
) -> tuple[
@@ -1141,6 +1465,8 @@ def _apply_prepare_form(
enabled_overrides = [entry for entry in overrides if entry.get("enabled")]
_sync_pronunciation_overrides(pending)
return (
chunk_level_literal,
overrides,
@@ -1261,6 +1587,13 @@ def _service() -> ConversionService:
return current_app.extensions["conversion_service"]
def _require_pending_job(pending_id: str) -> PendingJob:
pending = _service().get_pending_job(pending_id)
if not pending:
abort(404)
return cast(PendingJob, pending)
def _build_voice_catalog() -> List[Dict[str, str]]:
catalog: List[Dict[str, str]] = []
gender_map = {"f": "Female", "m": "Male"}
@@ -2101,6 +2434,74 @@ def api_speaker_preview() -> ResponseReturnValue:
return response
@api_bp.get("/pending/<pending_id>/entities")
def api_pending_entities(pending_id: str) -> ResponseReturnValue:
pending = _require_pending_job(pending_id)
refresh_flag = (request.args.get("refresh") or "").strip().lower()
expected_cache = (request.args.get("cache_key") or "").strip()
refresh_requested = refresh_flag in {"1", "true", "yes", "force"}
if expected_cache and expected_cache != (pending.entity_cache_key or ""):
refresh_requested = True
if refresh_requested or not pending.entity_summary:
_refresh_entity_summary(pending, pending.chapters)
_service().store_pending_job(pending)
return jsonify(_pending_entities_payload(pending))
@api_bp.post("/pending/<pending_id>/entities/refresh")
def api_refresh_pending_entities(pending_id: str) -> ResponseReturnValue:
pending = _require_pending_job(pending_id)
_refresh_entity_summary(pending, pending.chapters)
_service().store_pending_job(pending)
return jsonify(_pending_entities_payload(pending))
@api_bp.get("/pending/<pending_id>/manual-overrides")
def api_list_manual_overrides(pending_id: str) -> ResponseReturnValue:
pending = _require_pending_job(pending_id)
return jsonify(
{
"overrides": pending.manual_overrides or [],
"pronunciation_overrides": pending.pronunciation_overrides or [],
"language": pending.language or "en",
}
)
@api_bp.post("/pending/<pending_id>/manual-overrides")
def api_upsert_manual_override(pending_id: str) -> ResponseReturnValue:
pending = _require_pending_job(pending_id)
payload = request.get_json(silent=True) or {}
if not isinstance(payload, Mapping):
abort(400, "Invalid override payload")
try:
override = _upsert_manual_override(pending, payload)
except ValueError as exc:
abort(400, str(exc))
_service().store_pending_job(pending)
return jsonify({"override": override, **_pending_entities_payload(pending)})
@api_bp.delete("/pending/<pending_id>/manual-overrides/<override_id>")
def api_delete_manual_override(pending_id: str, override_id: str) -> ResponseReturnValue:
pending = _require_pending_job(pending_id)
deleted = _delete_manual_override(pending, override_id)
if not deleted:
abort(404)
_service().store_pending_job(pending)
return jsonify({"deleted": True, **_pending_entities_payload(pending)})
@api_bp.get("/pending/<pending_id>/manual-overrides/search")
def api_search_manual_override_candidates(pending_id: str) -> ResponseReturnValue:
pending = _require_pending_job(pending_id)
query = (request.args.get("q") or request.args.get("query") or "").strip()
limit_param = request.args.get("limit")
limit_value = _coerce_int(limit_param, 15, minimum=1, maximum=50) if limit_param is not None else 15
results = _search_manual_override_candidates(pending, query, limit=limit_value)
return jsonify({"query": query, "limit": limit_value, "results": results})
@web_bp.post("/jobs")
def enqueue_job() -> ResponseReturnValue:
service = _service()
@@ -2310,6 +2711,8 @@ def enqueue_job() -> ResponseReturnValue:
analysis_requested=initial_analysis,
)
_refresh_entity_summary(pending, pending.chapters)
service.store_pending_job(pending)
pending.applied_speaker_config = selected_speaker_config or None
if config_languages:
@@ -2323,20 +2726,14 @@ def enqueue_job() -> ResponseReturnValue:
@web_bp.get("/jobs/prepare/<pending_id>")
def prepare_job(pending_id: str) -> str:
pending = _service().get_pending_job(pending_id)
if not pending:
abort(404)
pending = cast(PendingJob, pending)
pending = _require_pending_job(pending_id)
return _render_prepare_page(pending, active_step="chapters")
@web_bp.post("/jobs/prepare/<pending_id>/analyze")
def analyze_pending_job(pending_id: str) -> ResponseReturnValue:
service = _service()
pending = service.get_pending_job(pending_id)
if not pending:
abort(404)
pending = cast(PendingJob, pending)
pending = _require_pending_job(pending_id)
(
chunk_level_literal,
@@ -2412,21 +2809,20 @@ def analyze_pending_job(pending_id: str) -> ResponseReturnValue:
if selected_total:
pending.total_characters = selected_total
_sync_pronunciation_overrides(pending)
service.store_pending_job(pending)
notice_message = "Speaker analysis updated."
notice_message = "Entity insights updated."
if persist_config_requested and config_name:
notice_message = "Speaker analysis updated and configuration saved."
return _render_prepare_page(pending, notice=notice_message, active_step="speakers")
notice_message = "Entity insights updated and configuration saved."
return _render_prepare_page(pending, notice=notice_message, active_step="entities")
@web_bp.post("/jobs/prepare/<pending_id>")
def finalize_job(pending_id: str) -> ResponseReturnValue:
service = _service()
pending = service.get_pending_job(pending_id)
if not pending:
abort(404)
pending = cast(PendingJob, pending)
pending = _require_pending_job(pending_id)
(
chunk_level_literal,
@@ -2443,7 +2839,7 @@ def finalize_job(pending_id: str) -> ResponseReturnValue:
return _render_prepare_page(
pending,
error=" ".join(errors),
active_step=request.form.get("active_step") or "speakers",
active_step=request.form.get("active_step") or "entities",
)
if pending.speaker_mode != "multi":
@@ -2458,12 +2854,14 @@ def finalize_job(pending_id: str) -> ResponseReturnValue:
)
active_step = (request.form.get("active_step") or "chapters").strip().lower()
if active_step == "speakers":
active_step = "entities"
raw_chunks = build_chunks_for_chapters(enabled_overrides, level=chunk_level_literal)
analysis_chunks = build_chunks_for_chapters(enabled_overrides, level="sentence")
is_multi = pending.speaker_mode == "multi"
analysis_requested = bool(getattr(pending, "analysis_requested", False))
should_force_speakers = is_multi and active_step != "speakers"
should_force_entities = is_multi and active_step != "entities"
if analysis_requested:
existing_roster: Optional[Mapping[str, Any]] = pending.speakers
@@ -2477,7 +2875,7 @@ def finalize_job(pending_id: str) -> ResponseReturnValue:
config_name = pending.applied_speaker_config or selected_config
speaker_config_payload = get_config(config_name) if config_name else None
run_analysis = is_multi and (should_force_speakers or analysis_requested)
run_analysis = is_multi and (should_force_entities or analysis_requested)
processed_chunks, roster, analysis_payload, config_languages, updated_config = _prepare_speaker_metadata(
chapters=enabled_overrides,
chunks=raw_chunks,
@@ -2511,15 +2909,17 @@ def finalize_job(pending_id: str) -> ResponseReturnValue:
if selected_total:
pending.total_characters = selected_total
if should_force_speakers:
notice_message = "Review speaker assignments before queuing."
_sync_pronunciation_overrides(pending)
if should_force_entities:
notice_message = "Review entity settings before queuing."
if persist_config_requested and config_key:
notice_message = "Configuration saved. Review speaker assignments before queuing."
notice_message = "Configuration saved. Review entity settings before queuing."
service.store_pending_job(pending)
return _render_prepare_page(
pending,
notice=notice_message,
active_step="speakers",
active_step="entities",
)
total_characters = selected_total or pending.total_characters
@@ -2559,6 +2959,9 @@ def finalize_job(pending_id: str) -> ResponseReturnValue:
speaker_analysis=pending.speaker_analysis,
speaker_analysis_threshold=pending.speaker_analysis_threshold,
analysis_requested=getattr(pending, "analysis_requested", False),
entity_summary=pending.entity_summary,
manual_overrides=pending.manual_overrides,
pronunciation_overrides=pending.pronunciation_overrides,
)
if config_languages:
@@ -2620,14 +3023,16 @@ def _render_prepare_page(
) or "chapters"
normalized_step = (active_step or "chapters").strip().lower()
if normalized_step not in {"chapters", "speakers"}:
if normalized_step == "speakers":
normalized_step = "entities"
if normalized_step not in {"chapters", "entities"}:
normalized_step = "chapters"
is_multi = pending.speaker_mode == "multi"
if normalized_step == "speakers" and not is_multi:
if normalized_step == "entities" and not is_multi:
normalized_step = "chapters"
template_name = "prepare_speakers.html" if normalized_step == "speakers" else "prepare_chapters.html"
template_name = "prepare_entities.html" if normalized_step == "entities" else "prepare_chapters.html"
return render_template(
template_name,
+28 -3
View File
@@ -24,7 +24,7 @@ def _create_set_event() -> threading.Event:
return event
STATE_VERSION = 5
STATE_VERSION = 6
_JOB_LOGGER = logging.getLogger("abogen.jobs")
@@ -133,8 +133,9 @@ class Job:
analysis_requested: bool = False
speaker_voice_languages: List[str] = field(default_factory=list)
applied_speaker_config: Optional[str] = None
speaker_voice_languages: List[str] = field(default_factory=list)
applied_speaker_config: Optional[str] = None
entity_summary: Dict[str, Any] = field(default_factory=dict)
manual_overrides: List[Dict[str, Any]] = field(default_factory=list)
pronunciation_overrides: List[Dict[str, Any]] = field(default_factory=list)
def add_log(self, message: str, level: str = "info") -> None:
entry = JobLog(timestamp=time.time(), message=message, level=level)
@@ -197,6 +198,9 @@ class Job:
"analysis_requested": self.analysis_requested,
"speaker_voice_languages": list(self.speaker_voice_languages),
"applied_speaker_config": self.applied_speaker_config,
"entity_summary": dict(self.entity_summary),
"manual_overrides": [dict(entry) for entry in self.manual_overrides],
"pronunciation_overrides": [dict(entry) for entry in self.pronunciation_overrides],
}
@@ -239,6 +243,10 @@ class PendingJob:
analysis_requested: bool = False
speaker_voice_languages: List[str] = field(default_factory=list)
applied_speaker_config: Optional[str] = None
entity_summary: Dict[str, Any] = field(default_factory=dict)
manual_overrides: List[Dict[str, Any]] = field(default_factory=list)
pronunciation_overrides: List[Dict[str, Any]] = field(default_factory=list)
entity_cache_key: Optional[str] = None
class ConversionService:
@@ -311,6 +319,9 @@ class ConversionService:
speaker_analysis: Optional[Mapping[str, Any]] = None,
speaker_analysis_threshold: int = 3,
analysis_requested: bool = False,
entity_summary: Optional[Mapping[str, Any]] = None,
manual_overrides: Optional[Iterable[Mapping[str, Any]]] = None,
pronunciation_overrides: Optional[Iterable[Mapping[str, Any]]] = None,
) -> Job:
job_id = uuid.uuid4().hex
normalized_metadata = self._normalize_metadata_tags(metadata_tags)
@@ -354,6 +365,9 @@ class ConversionService:
speaker_analysis=dict(speaker_analysis or {}),
speaker_analysis_threshold=int(speaker_analysis_threshold or 3),
analysis_requested=bool(analysis_requested),
entity_summary=dict(entity_summary or {}),
manual_overrides=[dict(entry) for entry in manual_overrides] if manual_overrides else [],
pronunciation_overrides=[dict(entry) for entry in pronunciation_overrides] if pronunciation_overrides else [],
)
with self._lock:
self._jobs[job_id] = job
@@ -505,6 +519,9 @@ class ConversionService:
speaker_analysis=job.speaker_analysis,
speaker_analysis_threshold=job.speaker_analysis_threshold,
analysis_requested=job.analysis_requested,
entity_summary=job.entity_summary,
manual_overrides=job.manual_overrides,
pronunciation_overrides=job.pronunciation_overrides,
)
new_job.speaker_voice_languages = list(job.speaker_voice_languages)
@@ -727,6 +744,9 @@ class ConversionService:
"speaker_analysis": dict(job.speaker_analysis),
"speaker_analysis_threshold": job.speaker_analysis_threshold,
"analysis_requested": job.analysis_requested,
"entity_summary": dict(job.entity_summary),
"manual_overrides": [dict(entry) for entry in job.manual_overrides],
"pronunciation_overrides": [dict(entry) for entry in job.pronunciation_overrides],
}
def _persist_state(self) -> None:
@@ -844,6 +864,11 @@ class ConversionService:
payload.get("speaker_analysis_threshold", job.speaker_analysis_threshold or 3)
)
job.analysis_requested = bool(payload.get("analysis_requested", job.analysis_requested))
job.entity_summary = payload.get("entity_summary", {})
job.manual_overrides = [dict(entry) for entry in payload.get("manual_overrides", []) if isinstance(entry, Mapping)]
job.pronunciation_overrides = [
dict(entry) for entry in payload.get("pronunciation_overrides", []) if isinstance(entry, Mapping)
]
job.pause_event.set()
return job
+608 -1
View File
@@ -450,7 +450,7 @@ document.addEventListener("DOMContentLoaded", () => {
analysisButtons.forEach((button) => {
button.addEventListener("click", () => {
if (activeStepInput) {
activeStepInput.value = "speakers";
activeStepInput.value = "entities";
}
});
});
@@ -1082,6 +1082,613 @@ document.addEventListener("DOMContentLoaded", () => {
renderVoiceList(elements);
}
const entitySummaryData = parseJSONScript("entity-summary-data") || {};
const entityCacheKeyData = parseJSONScript("entity-cache-key");
const manualOverridesSeed = parseJSONScript("manual-overrides-data") || [];
const pronunciationOverridesSeed = parseJSONScript("pronunciation-overrides-data") || [];
const entityTabs = form.querySelector('[data-role="entity-tabs"]');
const entitiesUrl = form.dataset.entitiesUrl || "";
const manualUpsertUrl = form.dataset.manualUpsertUrl || "";
const manualDeleteUrlTemplate = form.dataset.manualDeleteUrlTemplate || "";
const manualSearchUrl = form.dataset.manualSearchUrl || "";
const baseVoice = form.dataset.baseVoice || form.dataset.voice || "";
const languageCode = form.dataset.language || "en";
const defaultSpeed = form.dataset.speed || "1.0";
const useGpuDefault = form.dataset.useGpu || "true";
const entityState = {
summary: entitySummaryData && typeof entitySummaryData === "object" ? entitySummaryData : {},
cacheKey: typeof entityCacheKeyData === "string" ? entityCacheKeyData : "",
manualOverrides: Array.isArray(manualOverridesSeed) ? [...manualOverridesSeed] : [],
pronunciationOverrides: Array.isArray(pronunciationOverridesSeed) ? [...pronunciationOverridesSeed] : [],
};
let highlightedOverrideId = "";
const pendingOverrideSaves = new Map();
if (entityTabs) {
const tabButtons = Array.from(entityTabs.querySelectorAll('[data-role="entity-tab"]'));
const tabPanels = new Map(
Array.from(entityTabs.querySelectorAll('[data-role="entity-panel"]')).map((panel) => [panel.dataset.panel || "", panel])
);
const entitySummaryContainer = entityTabs.querySelector('[data-role="entity-summary"]');
const entityStatsNode = entitySummaryContainer?.querySelector('[data-role="entity-stats"]');
const entityListNode = entitySummaryContainer?.querySelector('[data-role="entity-list"]');
const entityRowTemplate = entitySummaryContainer?.querySelector('template[data-role="entity-row-template"]');
const entitiesRefreshButton = entitySummaryContainer?.querySelector('[data-role="entities-refresh"]');
const manualOverridesRoot = entityTabs.querySelector('[data-role="manual-overrides"]');
const manualOverrideList = manualOverridesRoot?.querySelector('[data-role="manual-override-list"]');
const manualOverrideTemplate = manualOverridesRoot?.querySelector('template[data-role="manual-override-template"]');
const manualOverrideResultsList = manualOverridesRoot?.querySelector('[data-role="manual-override-results"]');
const manualOverrideQueryInput = manualOverridesRoot?.querySelector('[data-role="manual-override-query"]');
const manualOverrideSearchButton = manualOverridesRoot?.querySelector('[data-role="manual-override-search"]');
const manualOverrideAddCustomButton = manualOverridesRoot?.querySelector('[data-role="manual-override-add-custom"]');
const manualOverridesEmpty = manualOverridesRoot?.querySelector('[data-role="manual-overrides-empty"]');
const cloneTemplate = (template) => {
if (!template) return null;
if (template.content && template.content.firstElementChild) {
return template.content.firstElementChild.cloneNode(true);
}
return template.cloneNode(true);
};
const formatMentions = (value) => {
const count = Number(value || 0);
return `${count.toLocaleString()} mention${count === 1 ? "" : "s"}`;
};
function activateEntityTab(panelKey) {
tabButtons.forEach((button) => {
const isActive = button.dataset.panel === panelKey;
button.classList.toggle("is-active", isActive);
button.setAttribute("aria-selected", isActive ? "true" : "false");
});
tabPanels.forEach((panel, key) => {
if (!panel) return;
const isActive = key === panelKey;
panel.classList.toggle("is-active", isActive);
panel.hidden = !isActive;
panel.setAttribute("aria-hidden", isActive ? "false" : "true");
});
}
function populateVoiceOptions(select, selectedVoice) {
if (!select) return;
const narratorLabel = baseVoice ? `Use narrator voice (${baseVoice})` : "Use narrator voice";
select.innerHTML = "";
const narratorOption = document.createElement("option");
narratorOption.value = "";
narratorOption.textContent = narratorLabel;
if (!selectedVoice) {
narratorOption.selected = true;
}
select.appendChild(narratorOption);
voiceCatalog.forEach((voice) => {
const option = document.createElement("option");
option.value = voice.id;
option.textContent = `${voice.display_name} · ${voice.language_label} · ${voice.gender}`;
if (selectedVoice === voice.id) {
option.selected = true;
}
select.appendChild(option);
});
if (selectedVoice) {
const hasMatch = Array.from(select.options).some((option) => option.value === selectedVoice);
if (!hasMatch) {
const fallback = document.createElement("option");
fallback.value = selectedVoice;
fallback.textContent = selectedVoice;
fallback.selected = true;
select.appendChild(fallback);
}
}
}
function renderEntitySummary() {
if (!entityListNode) return;
const summary = entityState.summary || {};
const stats = summary.stats || {};
const errors = Array.isArray(summary.errors) ? summary.errors : [];
if (entityStatsNode) {
if (errors.length) {
entityStatsNode.textContent = errors.join(" · ");
} else if (stats.processed) {
const parts = [];
if (typeof stats.chapters === "number") {
parts.push(`${stats.chapters} chapter${stats.chapters === 1 ? "" : "s"}`);
}
if (typeof stats.tokens === "number") {
parts.push(`${stats.tokens.toLocaleString()} tokens processed`);
}
entityStatsNode.textContent = parts.length ? parts.join(" · ") : "Entity analysis up to date.";
} else {
entityStatsNode.textContent = "Entity analysis will populate once you continue from chapters.";
}
}
entityListNode.innerHTML = "";
const entries = Array.isArray(summary.entities) ? summary.entities : [];
if (!entries.length) {
const emptyItem = document.createElement("li");
emptyItem.className = "entity-summary__empty";
emptyItem.textContent = "No entities detected yet.";
entityListNode.appendChild(emptyItem);
return;
}
entries.slice(0, 120).forEach((entity) => {
const item = cloneTemplate(entityRowTemplate);
if (!item) return;
const normalized = entity.normalized || entity.label || entity.token || "";
const tokenLabel = entity.label || entity.token || normalized || "Untitled entity";
item.dataset.entityId = entity.id || normalized || tokenLabel;
const labelEl = item.querySelector('[data-role="entity-label"]');
if (labelEl) {
labelEl.textContent = tokenLabel;
}
const kindEl = item.querySelector('[data-role="entity-kind"]');
if (kindEl) {
const kind = entity.kind || entity.category || "";
kindEl.textContent = kind;
kindEl.hidden = !kind;
}
const countEl = item.querySelector('[data-role="entity-count"]');
if (countEl) {
countEl.textContent = formatMentions(entity.count);
}
const samplesContainer = item.querySelector('[data-role="entity-samples"]');
if (samplesContainer) {
samplesContainer.innerHTML = "";
const samples = Array.isArray(entity.samples) ? entity.samples : [];
if (!samples.length) {
const hint = document.createElement("p");
hint.className = "hint";
hint.textContent = "No sample sentences captured yet.";
samplesContainer.appendChild(hint);
} else {
const list = document.createElement("ul");
list.className = "entity-summary__samples-list";
samples.slice(0, 3).forEach((sample) => {
const text = typeof sample === "string" ? sample : sample?.excerpt;
if (!text) return;
const entry = document.createElement("li");
entry.textContent = text;
list.appendChild(entry);
});
samplesContainer.appendChild(list);
}
}
const button = item.querySelector('[data-role="entity-add-override"]');
if (button) {
const hasOverride = entityState.manualOverrides.some((entry) => {
const candidate = entry?.normalized || entry?.token || "";
return candidate && candidate.toLowerCase() === normalized.toLowerCase();
});
button.dataset.entityToken = entity.label || entity.token || "";
button.dataset.entityNormalized = normalized;
const sampleContext = Array.isArray(entity.samples) && entity.samples.length
? typeof entity.samples[0] === "string"
? entity.samples[0]
: entity.samples[0]?.excerpt || ""
: "";
if (sampleContext) {
button.dataset.entityContext = sampleContext;
}
button.textContent = hasOverride ? "Edit manual override" : "Add manual override";
}
entityListNode.appendChild(item);
});
}
function renderManualOverrides() {
if (!manualOverrideList) return;
manualOverrideList.innerHTML = "";
const overrides = Array.isArray(entityState.manualOverrides) ? entityState.manualOverrides : [];
if (!overrides.length) {
if (manualOverridesEmpty) {
manualOverridesEmpty.hidden = false;
}
return;
}
if (manualOverridesEmpty) {
manualOverridesEmpty.hidden = true;
}
overrides.forEach((override) => {
const node = cloneTemplate(manualOverrideTemplate);
if (!node) return;
const overrideId = override.id || override.normalized || override.token || "";
node.dataset.overrideId = overrideId;
node.dataset.token = override.token || "";
node.dataset.normalized = override.normalized || "";
node.dataset.context = override.context || "";
const labelEl = node.querySelector('[data-role="override-label"]');
if (labelEl) {
labelEl.textContent = override.token || override.normalized || "Manual override";
}
const notesEl = node.querySelector('[data-role="override-notes"]');
if (notesEl) {
const notes = override.notes || override.context || "";
if (notes) {
notesEl.textContent = notes;
} else {
notesEl.hidden = true;
}
}
const pronInput = node.querySelector('[data-role="manual-override-pronunciation"]');
if (pronInput) {
pronInput.value = override.pronunciation || "";
pronInput.placeholder = override.token || override.normalized || "";
pronInput.dataset.overrideId = overrideId;
}
const voiceSelect = node.querySelector('[data-role="manual-override-voice"]');
if (voiceSelect) {
populateVoiceOptions(voiceSelect, override.voice || "");
voiceSelect.dataset.overrideId = overrideId;
}
const previewButton = node.querySelector('[data-role="speaker-preview"]');
if (previewButton) {
const previewVoice = override.voice || voiceSelect?.value || baseVoice || "";
const previewText = override.pronunciation || override.token || "";
previewButton.dataset.overrideId = overrideId;
previewButton.dataset.previewText = previewText;
previewButton.dataset.voice = previewVoice;
previewButton.dataset.language = languageCode;
previewButton.dataset.speed = defaultSpeed;
previewButton.dataset.useGpu = useGpuDefault;
}
const deleteButton = node.querySelector('[data-role="manual-override-delete"]');
if (deleteButton) {
deleteButton.dataset.overrideId = overrideId;
}
const metaEl = node.querySelector('[data-role="manual-override-meta"]');
if (metaEl) {
const parts = [];
if (override.source) {
parts.push(`Source: ${override.source}`);
}
if (override.updated_at) {
const timestamp = Number(override.updated_at) * 1000;
if (!Number.isNaN(timestamp)) {
parts.push(`Updated ${new Date(timestamp).toLocaleString()}`);
}
}
metaEl.textContent = parts.join(" · ");
}
manualOverrideList.appendChild(node);
if (highlightedOverrideId && highlightedOverrideId === overrideId) {
node.classList.add("is-highlighted");
setTimeout(() => node.classList.remove("is-highlighted"), 2400);
node.scrollIntoView({ behavior: "smooth", block: "center" });
highlightedOverrideId = "";
}
});
}
function applyEntityPayload(payload, options = {}) {
if (payload && typeof payload === "object") {
if (payload.summary) {
entityState.summary = payload.summary;
}
if (Array.isArray(payload.manual_overrides)) {
entityState.manualOverrides = payload.manual_overrides;
}
if (Array.isArray(payload.pronunciation_overrides)) {
entityState.pronunciationOverrides = payload.pronunciation_overrides;
}
if (typeof payload.cache_key === "string") {
entityState.cacheKey = payload.cache_key;
}
}
if (options.highlightId) {
highlightedOverrideId = options.highlightId;
}
renderEntitySummary();
renderManualOverrides();
}
function collectOverridePayload(overrideId) {
if (!overrideId || !manualOverrideList) return null;
const selectorId = typeof CSS !== "undefined" && CSS.escape ? CSS.escape(overrideId) : overrideId.replace(/["\\]/g, "\\$&");
const node = manualOverrideList.querySelector(`[data-override-id="${selectorId}"]`);
if (!node) return null;
const pronInput = node.querySelector('[data-role="manual-override-pronunciation"]');
const voiceSelect = node.querySelector('[data-role="manual-override-voice"]');
return {
id: overrideId,
token: node.dataset.token || "",
normalized: node.dataset.normalized || "",
pronunciation: pronInput?.value?.trim() || "",
voice: voiceSelect?.value || "",
notes: "",
context: node.dataset.context || "",
};
}
async function saveManualOverride(overrideId) {
const payload = collectOverridePayload(overrideId);
if (!payload || !manualUpsertUrl) return;
try {
const response = await fetch(manualUpsertUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(`Override save failed with status ${response.status}`);
}
const data = await response.json();
applyEntityPayload(data, { highlightId: data.override?.id || payload.id });
} catch (error) {
console.error("Failed to save manual override", error);
}
}
function scheduleManualOverrideSave(overrideId) {
if (!overrideId) return;
if (pendingOverrideSaves.has(overrideId)) {
clearTimeout(pendingOverrideSaves.get(overrideId));
}
const timer = setTimeout(() => {
pendingOverrideSaves.delete(overrideId);
saveManualOverride(overrideId);
}, 400);
pendingOverrideSaves.set(overrideId, timer);
}
async function deleteManualOverride(overrideId) {
if (!overrideId || !manualDeleteUrlTemplate) return;
const targetUrl = manualDeleteUrlTemplate.replace("__OVERRIDE_ID__", encodeURIComponent(overrideId));
try {
const response = await fetch(targetUrl, { method: "DELETE" });
if (!response.ok) {
throw new Error(`Override delete failed with status ${response.status}`);
}
const data = await response.json();
applyEntityPayload(data);
} catch (error) {
console.error("Failed to delete manual override", error);
}
}
async function performEntitiesRefresh(force = false) {
if (!entitiesUrl) return;
try {
const url = new URL(entitiesUrl, window.location.origin);
if (force) {
url.searchParams.set("refresh", "1");
}
if (entityState.cacheKey) {
url.searchParams.set("cache_key", entityState.cacheKey);
}
const response = await fetch(url.toString(), { method: "GET" });
if (!response.ok) {
throw new Error(`Entity refresh failed with status ${response.status}`);
}
const data = await response.json();
applyEntityPayload(data);
} catch (error) {
console.error("Failed to refresh entity summary", error);
}
}
async function performManualOverrideSearch(query) {
if (!manualSearchUrl || !manualOverrideResultsList) return;
manualOverrideResultsList.innerHTML = "";
try {
const url = new URL(manualSearchUrl, window.location.origin);
if (query) {
url.searchParams.set("q", query);
}
const response = await fetch(url.toString(), { method: "GET" });
if (!response.ok) {
throw new Error(`Search failed with status ${response.status}`);
}
const data = await response.json();
const results = Array.isArray(data.results) ? data.results : [];
if (!results.length) {
const emptyItem = document.createElement("li");
emptyItem.className = "manual-overrides__results-empty";
emptyItem.textContent = query ? "No matches found." : "Start typing to search tokens.";
manualOverrideResultsList.appendChild(emptyItem);
return;
}
results.forEach((entry) => {
const item = document.createElement("li");
item.className = "manual-overrides__result";
const button = document.createElement("button");
button.type = "button";
button.className = "button button--ghost button--small";
button.dataset.role = "manual-override-result";
button.dataset.token = entry.token || entry.normalized || "";
if (entry.normalized) {
button.dataset.normalized = entry.normalized;
}
if (entry.context) {
button.dataset.context = entry.context;
} else if (Array.isArray(entry.samples) && entry.samples.length) {
const sample = entry.samples[0];
button.dataset.context = typeof sample === "string" ? sample : sample?.excerpt || "";
}
if (entry.pronunciation) {
button.dataset.pronunciation = entry.pronunciation;
}
if (entry.voice) {
button.dataset.voice = entry.voice;
}
button.dataset.source = entry.source || "search";
button.textContent = entry.token || entry.normalized || "Unnamed token";
item.appendChild(button);
manualOverrideResultsList.appendChild(item);
});
} catch (error) {
console.error("Failed to search manual override candidates", error);
}
}
async function createOverrideFromData(data) {
if (!data || !manualUpsertUrl) return;
const token = (data.token || "").trim();
if (!token) return;
const payload = {
token,
normalized: (data.normalized || "").trim(),
context: data.context || "",
pronunciation: data.pronunciation || "",
voice: data.voice || "",
source: data.source || "manual",
};
try {
const response = await fetch(manualUpsertUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(`Override creation failed with status ${response.status}`);
}
const body = await response.json();
const highlighted = body.override?.id || payload.normalized || payload.token;
applyEntityPayload(body, { highlightId: highlighted });
activateEntityTab("manual");
if (manualOverrideResultsList) {
manualOverrideResultsList.innerHTML = "";
}
} catch (error) {
console.error("Failed to create manual override", error);
}
}
tabButtons.forEach((button) => {
button.addEventListener("click", () => {
activateEntityTab(button.dataset.panel || "people");
});
});
if (entitiesRefreshButton) {
entitiesRefreshButton.addEventListener("click", () => {
performEntitiesRefresh(true);
});
}
if (entityListNode) {
entityListNode.addEventListener("click", (event) => {
const trigger = event.target.closest('[data-role="entity-add-override"]');
if (!trigger) return;
event.preventDefault();
createOverrideFromData({
token: trigger.dataset.entityToken || "",
normalized: trigger.dataset.entityNormalized || "",
context: trigger.dataset.entityContext || "",
source: "entity",
});
});
}
if (manualOverrideSearchButton) {
manualOverrideSearchButton.addEventListener("click", () => {
const query = manualOverrideQueryInput?.value?.trim() || "";
performManualOverrideSearch(query);
});
}
if (manualOverrideQueryInput) {
manualOverrideQueryInput.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
performManualOverrideSearch(manualOverrideQueryInput.value.trim());
}
});
}
if (manualOverrideAddCustomButton) {
manualOverrideAddCustomButton.addEventListener("click", () => {
const token = window.prompt("Enter the token to override:");
if (!token) return;
createOverrideFromData({ token: token.trim(), source: "manual" });
});
}
if (manualOverrideResultsList) {
manualOverrideResultsList.addEventListener("click", (event) => {
const resultButton = event.target.closest('[data-role="manual-override-result"]');
if (!resultButton) return;
event.preventDefault();
createOverrideFromData({
token: resultButton.dataset.token || "",
normalized: resultButton.dataset.normalized || "",
context: resultButton.dataset.context || "",
pronunciation: resultButton.dataset.pronunciation || "",
voice: resultButton.dataset.voice || "",
source: resultButton.dataset.source || "search",
});
});
}
if (manualOverrideList) {
manualOverrideList.addEventListener("input", (event) => {
const input = event.target.closest('[data-role="manual-override-pronunciation"]');
if (!input) return;
const overrideId = input.dataset.overrideId || input.closest('[data-override-id]')?.dataset.overrideId;
const container = input.closest(".manual-override");
const previewButton = container?.querySelector('[data-role="speaker-preview"]');
if (previewButton) {
previewButton.dataset.previewText = input.value.trim() || input.placeholder || "";
}
if (overrideId) {
scheduleManualOverrideSave(overrideId);
}
});
manualOverrideList.addEventListener("change", (event) => {
const select = event.target.closest('[data-role="manual-override-voice"]');
if (!select) return;
const overrideId = select.dataset.overrideId || select.closest('[data-override-id]')?.dataset.overrideId;
const container = select.closest(".manual-override");
const previewButton = container?.querySelector('[data-role="speaker-preview"]');
if (previewButton) {
previewButton.dataset.voice = select.value || baseVoice || select.dataset.defaultVoice || "";
}
if (overrideId) {
scheduleManualOverrideSave(overrideId);
}
});
manualOverrideList.addEventListener("click", (event) => {
const deleteButton = event.target.closest('[data-role="manual-override-delete"]');
if (!deleteButton) return;
event.preventDefault();
const overrideId = deleteButton.dataset.overrideId;
if (!overrideId) return;
deleteManualOverride(overrideId);
});
}
const initialTab = tabButtons.find((button) => button.classList.contains("is-active"));
activateEntityTab(initialTab?.dataset.panel || "people");
renderEntitySummary();
renderManualOverrides();
}
form.addEventListener("click", (event) => {
const genderMenu = event.target.closest('[data-role="gender-menu"]');
const genderPill = event.target.closest('[data-role="gender-pill"]');
+1 -1
View File
@@ -5,7 +5,7 @@
{% block content %}
<section class="card card--workflow">
<h1 class="card__title">Create a New Audiobook</h1>
<p class="card__subtitle">Kick off a fresh conversion with your manuscript or pasted text. You can fine-tune chapters and speakers in the next steps.</p>
<p class="card__subtitle">Kick off a fresh conversion with your manuscript or pasted text. You can fine-tune chapters and entities in the next steps.</p>
<div class="upload-card__dropzone" data-role="upload-dropzone" tabindex="0" role="button" aria-label="Open upload settings or drop a manuscript file">
<div class="upload-card__dropzone-content">
<span class="upload-card__icon" aria-hidden="true">&uarr;</span>
@@ -42,9 +42,9 @@
{% set narrator_voice = options.voices[0] %}
{% endif %}
{% set speed_display = '%.2f'|format(narrator_speed) %}
{% set step1_state = 'is-active' if modal_step in ['settings', 'upload'] else ('is-complete' if modal_step in ['chapters', 'speakers'] else '') %}
{% set step2_state = 'is-active' if modal_step == 'chapters' else ('is-complete' if modal_step == 'speakers' else '') %}
{% set step3_state = 'is-active' if modal_step == 'speakers' else '' %}
{% set step1_state = 'is-active' if modal_step in ['settings', 'upload'] else ('is-complete' if modal_step in ['chapters', 'entities'] else '') %}
{% set step2_state = 'is-active' if modal_step == 'chapters' else ('is-complete' if modal_step == 'entities' else '') %}
{% set step3_state = 'is-active' if modal_step == 'entities' else '' %}
<div class="modal" data-role="upload-modal" hidden>
<div class="modal__overlay" data-role="upload-modal-close" tabindex="-1"></div>
<div class="modal__content card card--modal wizard-card" role="dialog" aria-modal="true" aria-labelledby="upload-modal-title">
@@ -62,7 +62,7 @@
{% if is_multi %}
<span class="step-indicator__item {{ step3_state }}">
<span class="step-indicator__index">3</span>
<span class="step-indicator__label">Speakers</span>
<span class="step-indicator__label">Entities</span>
</span>
{% endif %}
</nav>
@@ -177,7 +177,7 @@
</section>
<section class="form-section">
<h3 class="form-section__title">Speakers &amp; casting</h3>
<h3 class="form-section__title">Entities &amp; casting</h3>
<div class="form-section__layout">
<div class="field-grid field-grid--two">
<div class="field field--stack">
@@ -212,7 +212,7 @@
<div class="field">
<label for="speaker_analysis_threshold">Speaker analysis minimum mentions</label>
<input type="number" min="1" max="25" id="speaker_analysis_threshold" name="speaker_analysis_threshold" value="{{ analysis_threshold_value }}" {{ 'disabled' if readonly else '' }}>
<p class="hint">Speakers appearing less often fall back to the narrator voice.</p>
<p class="hint">Entities appearing less often fall back to the narrator voice.</p>
</div>
</div>
<div class="field">
+4 -4
View File
@@ -27,15 +27,15 @@
</a>
{% if is_multi_speaker %}
<a class="step-indicator__item"
href="{{ url_for('web.prepare_job', pending_id=pending.id, step='speakers') }}">
href="{{ url_for('web.prepare_job', pending_id=pending.id, step='entities') }}">
<span class="step-indicator__index">3</span>
<span class="step-indicator__label">Speakers</span>
<span class="step-indicator__label">Entities</span>
</a>
{% endif %}
</nav>
<p class="modal__eyebrow">Step 2 of {{ total_steps }}</p>
<h2 class="modal__title" id="prepare-chapters-title">Select chapters</h2>
<p class="hint">Choose which chapters to convert. We'll analyse speakers automatically when you continue.</p>
<p class="hint">Choose which chapters to convert. We'll analyse entities automatically when you continue.</p>
</div>
<div class="wizard-card__aside">
<p class="wizard-card__filename" title="{{ pending.original_filename }}">{{ pending.original_filename }}</p>
@@ -168,7 +168,7 @@
data-step-toggle="analysis"
formaction="{{ url_for('web.analyze_pending_job', pending_id=pending.id) }}"
formmethod="post">
Continue to speakers
Continue to entities
</button>
{% endif %}
<button type="submit" class="button" data-step-toggle="finalize"{% if is_multi_speaker %} hidden aria-hidden="true"{% endif %}>
+580
View File
@@ -0,0 +1,580 @@
{% extends "base.html" %}
{% block title %}Prepare · {{ pending.original_filename }}{% endblock %}
{% set is_multi_speaker = pending.speaker_mode == 'multi' %}
{% set analysis = pending.speaker_analysis or {} %}
{% set analysis_speakers = analysis.get('speakers', {}) %}
{% set speaker_configs = options.speaker_configs or [] %}
{% set recommended_template = options.speaker_pronunciation_sentence or "This is {{name}} speaking." %}
{% set total_steps = 3 if is_multi_speaker else 2 %}
{% block content %}
<section class="wizard-page wizard-page--modal" data-step="entities">
<div class="modal modal--wizard" data-role="wizard-modal" data-open="true">
<div class="modal__overlay" aria-hidden="true"></div>
<div class="modal__content card card--modal wizard-card" role="dialog" aria-modal="true" aria-labelledby="prepare-entities-title">
<header class="modal__header wizard-card__header">
<div class="wizard-card__headline">
<nav class="step-indicator" aria-label="Audiobook workflow">
<button type="button"
class="step-indicator__item is-complete"
data-role="open-upload-modal">
<span class="step-indicator__index">1</span>
<span class="step-indicator__label">Upload &amp; settings</span>
</button>
<a class="step-indicator__item is-complete"
href="{{ url_for('web.prepare_job', pending_id=pending.id, step='chapters') }}">
<span class="step-indicator__index">2</span>
<span class="step-indicator__label">Chapters</span>
</a>
{% if is_multi_speaker %}
<a class="step-indicator__item is-active"
aria-current="step"
href="{{ url_for('web.prepare_job', pending_id=pending.id, step='entities') }}">
<span class="step-indicator__index">3</span>
<span class="step-indicator__label">Entities</span>
</a>
{% endif %}
</nav>
<p class="modal__eyebrow">Step 3 of {{ total_steps }}</p>
<h2 class="modal__title" id="prepare-entities-title">Review entities</h2>
<p class="hint">Assign voices, tune pronunciations, and curate manual overrides before queueing the conversion.</p>
</div>
<div class="wizard-card__aside">
<p class="wizard-card__filename" title="{{ pending.original_filename }}">{{ pending.original_filename }}</p>
</div>
</header>
<form method="post"
action="{{ url_for('web.finalize_job', pending_id=pending.id) }}"
class="prepare-form"
id="prepare-form"
data-speaker-mode="{{ pending.speaker_mode }}"
data-analyze-url="{{ url_for('web.analyze_pending_job', pending_id=pending.id) }}"
data-entities-url="{{ url_for('api.api_pending_entities', pending_id=pending.id) }}"
data-manual-list-url="{{ url_for('api.api_list_manual_overrides', pending_id=pending.id) }}"
data-manual-upsert-url="{{ url_for('api.api_upsert_manual_override', pending_id=pending.id) }}"
data-manual-delete-url-template="{{ url_for('api.api_delete_manual_override', pending_id=pending.id, override_id='__OVERRIDE_ID__') }}"
data-manual-search-url="{{ url_for('api.api_search_manual_override_candidates', pending_id=pending.id) }}"
data-language="{{ pending.language }}"
data-base-voice="{{ pending.voice }}"
data-speed="{{ '%.2f'|format(pending.speed) }}"
data-use-gpu="{{ 'true' if pending.use_gpu else 'false' }}">
<input type="hidden" name="active_step" value="entities" data-role="active-step-input">
<div class="wizard-hidden-inputs" aria-hidden="true">
<input type="hidden" name="chunk_level" value="{{ pending.chunk_level }}">
<input type="hidden" name="speaker_mode" value="{{ pending.speaker_mode }}">
<input type="hidden" name="speaker_analysis_threshold" value="{{ pending.speaker_analysis_threshold }}">
<input type="hidden" name="chapter_intro_delay" value="{{ '%.2f'|format(pending.chapter_intro_delay) }}">
{% if pending.generate_epub3 %}
<input type="hidden" name="generate_epub3" value="true">
{% endif %}
{% for chapter in pending.chapters %}
{% set selected_option = '__default' %}
{% if chapter.voice_profile %}
{% set selected_option = 'profile:' ~ chapter.voice_profile %}
{% elif chapter.voice %}
{% set selected_option = 'voice:' ~ chapter.voice %}
{% elif chapter.voice_formula %}
{% set selected_option = 'formula' %}
{% endif %}
{% if chapter.enabled %}
<input type="hidden" name="chapter-{{ loop.index0 }}-enabled" value="on">
{% endif %}
<input type="hidden" name="chapter-{{ loop.index0 }}-title" value="{{ chapter.title }}">
<input type="hidden" name="chapter-{{ loop.index0 }}-voice" value="{{ selected_option }}">
{% if chapter.voice_formula %}
<input type="hidden" name="chapter-{{ loop.index0 }}-formula" value="{{ chapter.voice_formula }}">
{% endif %}
{% endfor %}
</div>
<div class="modal__body wizard-card__body">
{% if error %}
<div class="alert alert--error">{{ error }}</div>
{% endif %}
{% if notice %}
<div class="alert alert--info">{{ notice }}</div>
{% elif pending.speaker_mode != 'multi' %}
<div class="alert alert--info">Multi-speaker mode is disabled. Switch back to chapters to enable additional voices.</div>
{% endif %}
<div class="entity-tabs" data-role="entity-tabs">
<div class="entity-tabs__nav" role="tablist" aria-label="Entity categories">
<button type="button"
class="entity-tabs__tab is-active"
data-role="entity-tab"
data-panel="people"
id="entity-tab-people"
aria-controls="entity-panel-people"
aria-selected="true"
role="tab">
People
</button>
<button type="button"
class="entity-tabs__tab"
data-role="entity-tab"
data-panel="entities"
id="entity-tab-entities"
aria-controls="entity-panel-entities"
aria-selected="false"
role="tab">
Entities
</button>
<button type="button"
class="entity-tabs__tab"
data-role="entity-tab"
data-panel="manual"
id="entity-tab-manual"
aria-controls="entity-panel-manual"
aria-selected="false"
role="tab">
Manual Overrides
</button>
</div>
<div class="entity-tabs__panels">
<section class="entity-tabs__panel is-active"
data-role="entity-panel"
data-panel="people"
id="entity-panel-people"
role="tabpanel"
aria-labelledby="entity-tab-people">
<section 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"
data-role="submit-speaker-analysis"
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>
<label class="toggle-pill">
<input type="checkbox" name="save_speaker_config" value="1">
<span>Save roster updates back to preset</span>
</label>
</div>
</div>
</section>
{% if pending.speakers %}
<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 pending.speakers.items() %}
{% set pronunciation_text = speaker.pronunciation or speaker.label %}
{% set selected_voice = speaker.resolved_voice or speaker.voice %}
{% set seen = namespace(values=[]) %}
{% set sample_quotes = speaker.sample_quotes or [] %}
{% set detected_gender = speaker.detected_gender or speaker.gender or 'unknown' %}
{% set current_gender = speaker.gender or detected_gender %}
{% set gender_label = 'Either' if current_gender == 'either' else (current_gender|title if current_gender != 'unknown' else 'Unknown') %}
{% set detected_label = 'Either' if detected_gender == 'either' else (detected_gender|title if detected_gender != 'unknown' else 'Unknown') %}
<li class="speaker-list__item"
data-speaker-id="{{ speaker_id }}"
data-default-pronunciation="{{ pronunciation_text }}">
<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-source="pronunciation"
data-preview-text="{{ pronunciation_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">
<div class="speaker-gender" data-role="speaker-gender" data-speaker-id="{{ speaker_id }}">
<button type="button"
class="chip speaker-gender__pill"
data-role="gender-pill"
data-current="{{ current_gender }}">
{{ gender_label }} voice
</button>
<div class="speaker-gender__menu" data-role="gender-menu" hidden>
<p class="hint">Detected: {{ detected_label }}</p>
<div class="speaker-gender__options">
<button type="button" class="chip" data-role="gender-option" data-value="female">Female</button>
<button type="button" class="chip" data-role="gender-option" data-value="male">Male</button>
<button type="button" class="chip" data-role="gender-option" data-value="either">Either</button>
<button type="button" class="chip" data-role="gender-option" data-value="{{ detected_gender }}" {% if detected_gender == current_gender %}data-state="active"{% endif %}>
Use detected ({{ detected_label }})
</button>
</div>
</div>
<input type="hidden" name="speaker-{{ speaker_id }}-gender" value="{{ current_gender }}" data-role="gender-input">
<input type="hidden" name="speaker-{{ speaker_id }}-detected-gender" value="{{ detected_gender }}">
</div>
{% 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="{{ pronunciation_text }}"
data-role="speaker-pronunciation"
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 }}">
<option value="" {% if not selected_voice %}selected{% endif %}>Use narrator voice ({{ pending.voice }})</option>
<option value="__custom_mix" data-role="custom-mix-option" {% if speaker.voice_formula %}selected{% else %}hidden disabled{% endif %}>
Custom mix
</option>
{% if speaker.recommended_voices %}
<optgroup label="Recommended">
{% for voice_id in speaker.recommended_voices[:6] %}
{% 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>
<button type="button"
class="button button--ghost button--small"
data-role="generate-voice"
data-speaker-id="{{ speaker_id }}">
Generate voice
</button>
<button type="button"
class="button button--ghost button--small"
data-role="speaker-preview"
data-preview-kind="generated"
data-speaker-id="{{ speaker_id }}"
data-preview-source="generated"
data-preview-text="{{ pronunciation_text|e }}"
data-language="{{ pending.language }}"
data-voice="{{ speaker.voice_formula or selected_voice or pending.voice }}"
data-speed="{{ '%.2f'|format(pending.speed) }}"
data-use-gpu="{{ 'true' if pending.use_gpu else 'false' }}"
{% if not speaker.voice_formula %}hidden{% endif %}>
Preview generated
</button>
</div>
<div class="speaker-list__mix" data-role="speaker-mix" {% if not speaker.voice_formula %}hidden{% endif %}>
<span class="tag">Custom mix</span>
<span data-role="speaker-mix-label">{{ speaker.voice_formula or '' }}</span>
<div class="speaker-list__mix-actions">
<button type="button"
class="button button--ghost button--small"
data-role="speaker-preview"
data-preview-source="mix"
data-speaker-id="{{ speaker_id }}"
data-preview-text="{{ pronunciation_text|e }}"
data-language="{{ pending.language }}"
data-voice="{{ speaker.voice_formula or selected_voice or pending.voice }}"
data-speed="{{ '%.2f'|format(pending.speed) }}"
data-use-gpu="{{ 'true' if pending.use_gpu else 'false' }}">
Preview mix
</button>
<button type="button" class="button button--ghost button--small" data-role="clear-mix">Clear</button>
</div>
</div>
<input type="hidden" name="speaker-{{ speaker_id }}-formula" value="{{ speaker.voice_formula or '' }}" data-role="speaker-formula">
</div>
<details class="speaker-list__samples" {% if not sample_quotes %}data-state="empty"{% endif %}>
<summary>Sample paragraphs</summary>
{% if sample_quotes %}
{% set first_sample = sample_quotes[0] if sample_quotes|length > 0 else None %}
{% set first_excerpt = first_sample.excerpt if first_sample is mapping else first_sample %}
{% set first_hint = first_sample.gender_hint if first_sample is mapping else '' %}
<article class="speaker-sample" data-role="speaker-sample">
<p data-role="sample-text">{{ first_excerpt }}</p>
<p class="hint" data-role="sample-hint" {% if not first_hint %}hidden{% endif %}>{{ first_hint }}</p>
<div class="speaker-sample__actions">
<button type="button"
class="button button--ghost button--small"
data-role="speaker-preview"
data-preview-source="sample"
data-speaker-id="{{ speaker_id }}"
data-preview-text="{{ first_excerpt }}"
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="0">
Preview in voice browser
</button>
{% if sample_quotes|length > 1 %}
<button type="button"
class="button button--ghost button--small"
data-role="speaker-next-sample">
Show another example
</button>
{% endif %}
</div>
</article>
{% else %}
<p class="hint">No paragraphs captured yet. Continue from Step 2 to gather dialogue samples automatically.</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 %}
</section>
<section class="entity-tabs__panel"
data-role="entity-panel"
data-panel="entities"
id="entity-panel-entities"
role="tabpanel"
aria-labelledby="entity-tab-entities"
hidden>
<div class="entity-summary" data-role="entity-summary">
<header class="entity-summary__header">
<h2>Entity insights</h2>
<div class="entity-summary__actions">
<button type="button" class="button button--ghost" data-role="entities-refresh">Refresh</button>
<button type="button" class="button button--ghost" data-role="entities-download" hidden>Download CSV</button>
</div>
</header>
<p class="hint">Review organisations, locations, and other proper nouns detected in the manuscript. Add manual overrides to customise pronunciations where needed.</p>
<div class="entity-summary__stats" data-role="entity-stats"></div>
<ul class="entity-summary__list" data-role="entity-list"></ul>
<template data-role="entity-row-template">
<li class="entity-summary__item" data-entity-id="">
<header class="entity-summary__item-head">
<div>
<span class="entity-summary__label" data-role="entity-label"></span>
<span class="entity-summary__kind" data-role="entity-kind"></span>
</div>
<div class="entity-summary__meta">
<span class="badge badge--muted" data-role="entity-count"></span>
<button type="button" class="button button--ghost button--small" data-role="entity-add-override">Add manual override</button>
</div>
</header>
<div class="entity-summary__samples" data-role="entity-samples"></div>
</li>
</template>
</div>
</section>
<section class="entity-tabs__panel"
data-role="entity-panel"
data-panel="manual"
id="entity-panel-manual"
role="tabpanel"
aria-labelledby="entity-tab-manual"
hidden>
<div class="manual-overrides" data-role="manual-overrides">
<header class="manual-overrides__header">
<h2>Manual overrides</h2>
<p class="hint">Search tokens from the book or add custom entries. Set pronunciations and assign voices to ensure previews and conversions use your preferred delivery.</p>
</header>
<div class="manual-overrides__search">
<label class="field" for="manual-override-query">
<span>Search manuscript tokens</span>
<input type="search" id="manual-override-query" data-role="manual-override-query" placeholder="Search by name or phrase">
</label>
<div class="manual-overrides__search-actions">
<button type="button" class="button button--ghost" data-role="manual-override-search">Search</button>
<button type="button" class="button button--ghost" data-role="manual-override-add-custom">Add custom token</button>
</div>
<ul class="manual-overrides__results" data-role="manual-override-results"></ul>
</div>
<div class="manual-overrides__list" data-role="manual-override-list"></div>
<template data-role="manual-override-template">
<article class="manual-override" data-override-id="">
<header class="manual-override__header">
<div>
<h3 class="manual-override__label" data-role="override-label"></h3>
<p class="manual-override__notes" data-role="override-notes"></p>
</div>
<div class="manual-override__actions">
<button type="button" class="button button--ghost button--small" data-role="speaker-preview" data-preview-source="manual">Preview</button>
<button type="button" class="button button--ghost button--small" data-role="manual-override-delete">Remove</button>
</div>
</header>
<div class="manual-override__body">
<label class="field" data-role="manual-override-pronunciation-label">
<span>Pronunciation</span>
<input type="text" data-role="manual-override-pronunciation" value="">
</label>
<label class="field">
<span>Assigned voice</span>
<select data-role="manual-override-voice" data-default-voice="{{ pending.voice }}">
<option value="">Use narrator voice ({{ pending.voice }})</option>
</select>
</label>
<div class="manual-override__meta" data-role="manual-override-meta"></div>
</div>
</article>
</template>
<p class="manual-overrides__empty" data-role="manual-overrides-empty" hidden>No overrides yet. Use search or add a custom entry to begin.</p>
</div>
</section>
</div>
</div>
</div>
<footer class="modal__footer wizard-card__footer">
<div class="wizard-card__footer-actions">
<a class="button button--ghost" href="{{ url_for('web.prepare_job', pending_id=pending.id, step='chapters') }}">Previous</a>
<button type="submit" class="button button--ghost" form="cancel-form">Cancel</button>
</div>
<div class="wizard-card__footer-actions">
<button type="submit" class="button">Queue conversion</button>
</div>
</footer>
</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>
<button type="button" class="button button--ghost" data-role="voice-modal-clear">Clear selection</button>
</aside>
<section class="voice-browser__catalog" aria-label="Voice catalog">
<ul class="voice-browser__list" data-role="voice-modal-list"></ul>
</section>
<section class="voice-browser__mix" data-role="voice-modal-mix">
<header class="voice-browser__mix-header">
<h3>Selected voices</h3>
<p class="tag" data-role="voice-modal-mix-total">Total weight: 0.00</p>
</header>
<div class="voice-browser__mix-list" data-role="voice-modal-mix-list"></div>
<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>Apply mix</button>
</div>
</section>
</section>
</div>
</div>
</div>
</div>
</div>
</section>
{% with pending=pending, readonly=True, active_step='entities' %}
{% include "partials/upload_modal.html" %}
{% endwith %}
{% endblock %}
{% block scripts %}
{{ super() }}
<script id="voice-sample-texts" type="application/json">{{ options.sample_voice_texts | tojson }}</script>
<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 id="entity-summary-data" type="application/json">{{ pending.entity_summary or {} | tojson }}</script>
<script id="entity-cache-key" type="application/json">{{ pending.entity_cache_key or '' | tojson }}</script>
<script id="manual-overrides-data" type="application/json">{{ pending.manual_overrides or [] | tojson }}</script>
<script id="pronunciation-overrides-data" type="application/json">{{ pending.pronunciation_overrides or [] | tojson }}</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='dashboard.js') }}"></script>
{% endblock %}
+1 -1
View File
@@ -1,3 +1,3 @@
{# This template is intentionally left empty.
Step-specific templates now live in `prepare_chapters.html` and `prepare_speakers.html`.
Step-specific templates now live in `prepare_chapters.html` and `prepare_entities.html`.
The file is kept as a placeholder to avoid breaking documentation references. #}
-259
View File
@@ -1,262 +1,3 @@
{% extends "base.html" %}
{% block title %}Prepare · {{ pending.original_filename }}{% endblock %}
{% set is_multi_speaker = pending.speaker_mode == 'multi' %}
{% set analysis = pending.speaker_analysis or {} %}
{% set analysis_speakers = analysis.get('speakers', {}) %}
{% set speaker_configs = options.speaker_configs or [] %}
{% set recommended_template = options.speaker_pronunciation_sentence or "This is {{name}} speaking." %}
{% set total_steps = 3 if is_multi_speaker else 2 %}
{% block content %}
<section class="wizard-page wizard-page--modal" data-step="speakers">
<div class="modal modal--wizard" data-role="wizard-modal" data-open="true">
<div class="modal__overlay" aria-hidden="true"></div>
<div class="modal__content card card--modal wizard-card" role="dialog" aria-modal="true" aria-labelledby="prepare-speakers-title">
<header class="modal__header wizard-card__header">
<div class="wizard-card__headline">
<nav class="step-indicator" aria-label="Audiobook workflow">
<button type="button"
class="step-indicator__item is-complete"
data-role="open-upload-modal">
<span class="step-indicator__index">1</span>
<span class="step-indicator__label">Upload &amp; settings</span>
</button>
<a class="step-indicator__item is-complete"
href="{{ url_for('web.prepare_job', pending_id=pending.id, step='chapters') }}">
<span class="step-indicator__index">2</span>
<span class="step-indicator__label">Chapters</span>
</a>
{% if is_multi_speaker %}
<a class="step-indicator__item is-active"
aria-current="step"
href="{{ url_for('web.prepare_job', pending_id=pending.id, step='speakers') }}">
<span class="step-indicator__index">3</span>
<span class="step-indicator__label">Speakers</span>
</a>
{% endif %}
</nav>
<p class="modal__eyebrow">Step 3 of {{ total_steps }}</p>
<h2 class="modal__title" id="prepare-speakers-title">Select speakers</h2>
<p class="hint">Assign voices, audition samples, and finalise your roster before queueing the conversion.</p>
</div>
<div class="wizard-card__aside">
<p class="wizard-card__filename" title="{{ pending.original_filename }}">{{ pending.original_filename }}</p>
</div>
</header>
<form method="post"
action="{{ url_for('web.finalize_job', pending_id=pending.id) }}"
class="prepare-form"
id="prepare-form"
data-speaker-mode="{{ pending.speaker_mode }}"
data-analyze-url="{{ url_for('web.analyze_pending_job', pending_id=pending.id) }}">
<input type="hidden" name="active_step" value="speakers" data-role="active-step-input">
<div class="wizard-hidden-inputs" aria-hidden="true">
<input type="hidden" name="chunk_level" value="{{ pending.chunk_level }}">
<input type="hidden" name="speaker_mode" value="{{ pending.speaker_mode }}">
<input type="hidden" name="speaker_analysis_threshold" value="{{ pending.speaker_analysis_threshold }}">
<input type="hidden" name="chapter_intro_delay" value="{{ '%.2f'|format(pending.chapter_intro_delay) }}">
{% if pending.generate_epub3 %}
<input type="hidden" name="generate_epub3" value="true">
{% endif %}
{% for chapter in pending.chapters %}
{% set selected_option = '__default' %}
{% if chapter.voice_profile %}
{% set selected_option = 'profile:' ~ chapter.voice_profile %}
{% elif chapter.voice %}
{% set selected_option = 'voice:' ~ chapter.voice %}
{% elif chapter.voice_formula %}
{% set selected_option = 'formula' %}
{% endif %}
{% if chapter.enabled %}
<input type="hidden" name="chapter-{{ loop.index0 }}-enabled" value="on">
{% endif %}
<input type="hidden" name="chapter-{{ loop.index0 }}-title" value="{{ chapter.title }}">
<input type="hidden" name="chapter-{{ loop.index0 }}-voice" value="{{ selected_option }}">
{% if chapter.voice_formula %}
<input type="hidden" name="chapter-{{ loop.index0 }}-formula" value="{{ chapter.voice_formula }}">
{% endif %}
{% endfor %}
</div>
<div class="modal__body wizard-card__body">
{% if error %}
<div class="alert alert--error">{{ error }}</div>
{% endif %}
{% if notice %}
<div class="alert alert--info">{{ notice }}</div>
{% elif pending.speaker_mode != 'multi' %}
<div class="alert alert--info">Multi-speaker mode is disabled. Switch back to chapters to enable additional voices.</div>
{% endif %}
<section 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"
data-role="submit-speaker-analysis"
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>
<label class="toggle-pill">
<input type="checkbox" name="save_speaker_config" value="1">
<span>Save roster updates back to preset</span>
</label>
</div>
</div>
</section>
{% if pending.speakers %}
<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 pending.speakers.items() %}
{% set pronunciation_text = speaker.pronunciation or speaker.label %}
{% set selected_voice = speaker.resolved_voice or speaker.voice %}
{% set seen = namespace(values=[]) %}
{% set sample_quotes = speaker.sample_quotes or [] %}
{% set detected_gender = speaker.detected_gender or speaker.gender or 'unknown' %}
{% set current_gender = speaker.gender or detected_gender %}
{% set gender_label = 'Either' if current_gender == 'either' else (current_gender|title if current_gender != 'unknown' else 'Unknown') %}
{% set detected_label = 'Either' if detected_gender == 'either' else (detected_gender|title if detected_gender != 'unknown' else 'Unknown') %}
<li class="speaker-list__item"
data-speaker-id="{{ speaker_id }}"
data-default-pronunciation="{{ pronunciation_text }}">
<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-source="pronunciation"
data-preview-text="{{ pronunciation_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">
<div class="speaker-gender" data-role="speaker-gender" data-speaker-id="{{ speaker_id }}">
<button type="button"
class="chip speaker-gender__pill"
data-role="gender-pill"
data-current="{{ current_gender }}">
{{ gender_label }} voice
</button>
<div class="speaker-gender__menu" data-role="gender-menu" hidden>
<p class="hint">Detected: {{ detected_label }}</p>
<div class="speaker-gender__options">
<button type="button" class="chip" data-role="gender-option" data-value="female">Female</button>
<button type="button" class="chip" data-role="gender-option" data-value="male">Male</button>
<button type="button" class="chip" data-role="gender-option" data-value="either">Either</button>
<button type="button" class="chip" data-role="gender-option" data-value="{{ detected_gender }}" {% if detected_gender == current_gender %}data-state="active"{% endif %}>
Use detected ({{ detected_label }})
</button>
</div>
</div>
<input type="hidden" name="speaker-{{ speaker_id }}-gender" value="{{ current_gender }}" data-role="gender-input">
<input type="hidden" name="speaker-{{ speaker_id }}-detected-gender" value="{{ detected_gender }}">
</div>
{% 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="{{ pronunciation_text }}"
data-role="speaker-pronunciation"
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 }}">
<option value="" {% if not selected_voice %}selected{% endif %}>Use narrator voice ({{ pending.voice }})</option>
<option value="__custom_mix" data-role="custom-mix-option" {% if speaker.voice_formula %}selected{% else %}hidden disabled{% endif %}>
Custom mix
</option>
{% if speaker.recommended_voices %}
<optgroup label="Recommended">
{% for voice_id in speaker.recommended_voices[:6] %}
{% 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>
<button type="button"
class="button button--ghost button--small"
data-role="generate-voice"
data-speaker-id="{{ speaker_id }}">
Generate voice
</button>
<button type="button"
class="button button--ghost button--small"
data-role="speaker-preview"
data-preview-kind="generated"
data-speaker-id="{{ speaker_id }}"
data-preview-source="generated"
data-preview-text="{{ pronunciation_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' }}"