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
+401
View File
@@ -0,0 +1,401 @@
from __future__ import annotations
import hashlib
import os
import re
import threading
import time
from collections import Counter
from dataclasses import dataclass, field
from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple
try: # pragma: no cover - fallback when spaCy not available during tests
import spacy # type: ignore[import-not-found]
except Exception: # pragma: no cover - spaCy optional during runtime bootstrap
spacy = None
_Language = Any # type: ignore[misc,assignment]
Doc = Any # type: ignore[misc,assignment]
Span = Any # type: ignore[misc,assignment]
_TITLE_PREFIXES = (
"mr",
"mrs",
"ms",
"miss",
"dr",
"prof",
"sir",
"madam",
"lady",
"lord",
"capt",
"captain",
"col",
"colonel",
"maj",
"major",
"sgt",
"sergeant",
"rev",
"father",
"mother",
"brother",
"sister",
)
_STOP_LABELS = {
"the",
"that",
"this",
"those",
"these",
"there",
"here",
"then",
"and",
"but",
"or",
"nor",
"so",
"yet",
}
_TITLE_PATTERN = re.compile(r"^(?:" + "|".join(re.escape(prefix) for prefix in _TITLE_PREFIXES) + r")\.?\s+", re.IGNORECASE)
_POSSESSIVE_PATTERN = re.compile(r"(?:'s|s|\u2019s)$", re.IGNORECASE)
_NON_WORD_PATTERN = re.compile(r"[^\w\s'-]+")
_MULTI_SPACE_PATTERN = re.compile(r"\s+")
@dataclass(slots=True)
class EntityRecord:
key: Tuple[str, str]
label: str
kind: str
category: str
count: int = 0
samples: List[Dict[str, Any]] = field(default_factory=list)
chapter_indices: set[int] = field(default_factory=set)
forms: Counter = field(default_factory=Counter)
first_position: Optional[Tuple[int, int]] = None
def register(self, *, chapter_index: int, position: int, text: str, sentence: Optional[str]) -> None:
self.count += 1
self.chapter_indices.add(chapter_index)
self.forms[text] += 1
if self.first_position is None:
self.first_position = (chapter_index, position)
if sentence and len(self.samples) < 5:
payload = {
"excerpt": sentence.strip(),
"chapter_index": chapter_index,
}
if payload not in self.samples:
self.samples.append(payload)
def as_dict(self, ordinal: int) -> Dict[str, Any]:
chapter_indices = sorted(self.chapter_indices)
first_chapter = chapter_indices[0] if chapter_indices else None
return {
"id": f"{self.category}_{ordinal}",
"label": self.label,
"normalized": self.key[1],
"category": self.category,
"kind": self.kind,
"count": self.count,
"samples": list(self.samples),
"chapter_indices": chapter_indices,
"first_chapter": first_chapter,
"forms": self.forms.most_common(6),
}
@dataclass(slots=True)
class EntityExtractionResult:
summary: Dict[str, Any]
cache_key: str
elapsed: float
errors: List[str]
class EntityModelError(RuntimeError):
pass
_MODEL_CACHE: Dict[str, Any] = {}
_MODEL_LOCK = threading.RLock()
def _resolve_model_name(language: str) -> str:
override = os.environ.get("ABOGEN_SPACY_MODEL")
if override:
return override.strip()
lowered = language.strip().lower()
if lowered.startswith("en"):
return "en_core_web_sm"
return "en_core_web_sm"
def _load_model(language: str) -> Any:
if spacy is None:
raise EntityModelError("spaCy is not available. Install spaCy to enable entity extraction.")
model_name = _resolve_model_name(language)
cache_key = model_name.lower()
with _MODEL_LOCK:
if cache_key in _MODEL_CACHE:
return _MODEL_CACHE[cache_key]
try:
nlp = spacy.load(model_name) # type: ignore[arg-type]
except OSError as exc: # pragma: no cover - external dependency failure
raise EntityModelError(
f"spaCy model '{model_name}' is not installed. Download it with "
"`python -m spacy download en_core_web_sm`."
) from exc
nlp.max_length = max(nlp.max_length, 2_000_000)
_MODEL_CACHE[cache_key] = nlp
return nlp
def _normalize_label(text: str) -> str:
if not text:
return ""
stripped = text.strip().strip("\"'`“”’")
if not stripped:
return ""
stripped = _TITLE_PATTERN.sub("", stripped)
stripped = _POSSESSIVE_PATTERN.sub("", stripped)
stripped = _NON_WORD_PATTERN.sub(" ", stripped)
stripped = _MULTI_SPACE_PATTERN.sub(" ", stripped)
stripped = stripped.strip()
if not stripped or stripped.lower() in _STOP_LABELS:
return ""
parts = stripped.split()
if not parts:
return ""
if len(parts) == 1 and len(parts[0]) <= 1:
return ""
# Normalise casing: preserve uppercase abbreviations, otherwise title case.
normalized_parts = []
for index, part in enumerate(parts):
if part.isupper():
normalized_parts.append(part)
elif part[:1].isupper():
normalized_parts.append(part[:1].upper() + part[1:])
elif index == 0:
normalized_parts.append(part[:1].upper() + part[1:])
else:
normalized_parts.append(part)
normalized = " ".join(normalized_parts).strip()
if normalized.lower() in _STOP_LABELS:
return ""
return normalized
def _token_key(value: str) -> str:
return _MULTI_SPACE_PATTERN.sub(" ", value.lower().strip()).strip()
def _iter_named_entities(doc: Any) -> Iterable[Any]: # type: ignore[override]
for ent in getattr(doc, "ents", ()):
if ent.label_ == "":
continue
yield ent
def _extract_propn_tokens(doc: Any) -> Iterable[Any]: # type: ignore[override]
seen: set[Tuple[int, int]] = set()
for ent in getattr(doc, "ents", ()): # guard multi-token spans
seen.add((ent.start, ent.end))
for token in doc:
if token.pos_ != "PROPN":
continue
span_key = (token.i, token.i + 1)
if span_key in seen:
continue
if token.is_stop:
continue
text = token.text.strip()
if not text:
continue
if token.ent_type_:
continue
yield doc[token.i : token.i + 1]
def _empty_result(cache_key: str, error: Optional[str] = None) -> EntityExtractionResult:
payload = {
"people": [],
"entities": [],
"index": {"tokens": []},
"stats": {
"tokens": 0,
"chapters": 0,
"processed": False,
},
"model": None,
}
errors = [error] if error else []
return EntityExtractionResult(summary=payload, cache_key=cache_key, elapsed=0.0, errors=errors)
def extract_entities(
chapters: Iterable[Mapping[str, Any]],
*,
language: str = "en",
) -> EntityExtractionResult:
start = time.perf_counter()
normalized_language = language or "en"
combined_hasher = hashlib.sha1()
chapter_texts: List[Tuple[int, str]] = []
for idx, chapter in enumerate(chapters):
text = chapter.get("text") if isinstance(chapter, Mapping) else None
text_value = str(text or "")
chapter_texts.append((idx, text_value))
if text_value:
combined_hasher.update(text_value.encode("utf-8", "ignore"))
cache_key = combined_hasher.hexdigest()
if not chapter_texts:
return _empty_result(cache_key)
try:
nlp = _load_model(normalized_language)
except EntityModelError as exc:
return _empty_result(cache_key, str(exc))
records: Dict[Tuple[str, str], EntityRecord] = {}
tokens_for_index: Dict[str, Dict[str, Any]] = {}
processed_tokens = 0
for chapter_index, text in chapter_texts:
trimmed = text.strip()
if not trimmed:
continue
if len(trimmed) + 1024 > nlp.max_length:
nlp.max_length = len(trimmed) + 1024
doc = nlp(trimmed)
def _register_span(span: Any, category_hint: Optional[str] = None) -> None:
nonlocal processed_tokens
cleaned = _normalize_label(span.text)
if not cleaned:
return
key = _token_key(cleaned)
if not key:
return
category = category_hint or ("people" if span.label_ == "PERSON" else "entities")
record_key = (category, key)
record = records.get(record_key)
if record is None:
record = EntityRecord(
key=record_key,
label=cleaned,
kind=span.label_ or ("PROPN" if category == "entities" else "PERSON"),
category=category,
)
records[record_key] = record
sentence = span.sent.text if hasattr(span, "sent") and span.sent is not None else None
record.register(
chapter_index=chapter_index,
position=span.start,
text=span.text,
sentence=sentence,
)
processed_tokens += 1
index_entry = tokens_for_index.get(key)
if index_entry is None:
index_entry = {
"token": record.label,
"normalized": key,
"category": category,
"count": 0,
"samples": [],
}
tokens_for_index[key] = index_entry
index_entry["count"] += 1
if sentence and len(index_entry["samples"]) < 3:
if sentence not in index_entry["samples"]:
index_entry["samples"].append(sentence)
for ent in _iter_named_entities(doc):
_register_span(ent)
for span in _extract_propn_tokens(doc):
_register_span(span, category_hint="entities")
elapsed = time.perf_counter() - start
people_records = [record for record in records.values() if record.category == "people"]
entity_records = [record for record in records.values() if record.category == "entities"]
people_records.sort(key=lambda rec: (-rec.count, rec.label))
entity_records.sort(key=lambda rec: (-rec.count, rec.label))
people_payload = [record.as_dict(index + 1) for index, record in enumerate(people_records)]
entity_payload = [record.as_dict(index + 1) for index, record in enumerate(entity_records)]
index_payload = sorted(tokens_for_index.values(), key=lambda item: (-item["count"], item["token"]))
summary = {
"people": people_payload,
"entities": entity_payload,
"index": {"tokens": index_payload},
"stats": {
"tokens": processed_tokens,
"chapters": len(chapter_texts),
"processed": True,
},
"model": {
"name": getattr(nlp, "meta", {}).get("name", "unknown"),
"version": getattr(nlp, "meta", {}).get("version", "unknown"),
"lang": getattr(nlp, "meta", {}).get("lang", normalized_language),
},
}
return EntityExtractionResult(summary=summary, cache_key=cache_key, elapsed=elapsed, errors=[])
def search_tokens(index: Mapping[str, Any], query: str, *, limit: int = 15) -> List[Dict[str, Any]]:
tokens = index.get("tokens") if isinstance(index, Mapping) else None
if not isinstance(tokens, list) or not query:
return []
normalized = query.strip().lower()
if not normalized:
return tokens[:limit]
results: List[Dict[str, Any]] = []
for entry in tokens:
token_label = str(entry.get("token", ""))
normalized_label = token_label.lower()
if normalized in normalized_label or normalized in str(entry.get("normalized", "")):
results.append(entry)
if len(results) >= limit:
break
return results
def merge_override(summary: Mapping[str, Any], overrides: Mapping[str, Mapping[str, Any]]) -> Dict[str, Any]:
if not isinstance(summary, Mapping):
return {"people": [], "entities": []}
merged_summary: Dict[str, Any] = dict(summary)
for key in ("people", "entities"):
items = summary.get(key)
if not isinstance(items, list):
continue
merged_items: List[Dict[str, Any]] = []
for entry in items:
if not isinstance(entry, Mapping):
continue
normalized = _token_key(str(entry.get("normalized") or entry.get("label") or ""))
merged = dict(entry)
if normalized and normalized in overrides:
merged_override = dict(overrides[normalized])
merged["override"] = merged_override
merged_items.append(merged)
merged_summary[key] = merged_items
return merged_summary
def normalize_token(token: str) -> str:
return _token_key(_normalize_label(token))
+228
View File
@@ -0,0 +1,228 @@
from __future__ import annotations
import sqlite3
import threading
import time
from pathlib import Path
from typing import Any, Dict, Iterable, List, Mapping, Optional
from .entity_analysis import normalize_token
from .utils import get_internal_cache_path, get_user_settings_dir
_DB_LOCK = threading.RLock()
_SCHEMA_VERSION = 1
def _store_path() -> Path:
try:
base_dir = Path(get_user_settings_dir())
except ModuleNotFoundError:
base_dir = Path(get_internal_cache_path("pronunciations"))
target = base_dir / "pronunciations.db"
target.parent.mkdir(parents=True, exist_ok=True)
return target
def _connect() -> sqlite3.Connection:
connection = sqlite3.connect(_store_path())
connection.execute("PRAGMA journal_mode=WAL")
connection.execute("PRAGMA foreign_keys=ON")
return connection
def _ensure_schema(conn: sqlite3.Connection) -> None:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS overrides (
id INTEGER PRIMARY KEY AUTOINCREMENT,
normalized TEXT NOT NULL,
token TEXT NOT NULL,
language TEXT NOT NULL,
pronunciation TEXT,
voice TEXT,
notes TEXT,
context TEXT,
usage_count INTEGER NOT NULL DEFAULT 0,
created_at REAL NOT NULL,
updated_at REAL NOT NULL,
UNIQUE(normalized, language)
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS metadata (
key TEXT PRIMARY KEY,
value INTEGER NOT NULL
)
"""
)
row = conn.execute("SELECT value FROM metadata WHERE key='schema_version'").fetchone()
if row is None:
conn.execute(
"INSERT OR REPLACE INTO metadata(key, value) VALUES('schema_version', ?)",
(_SCHEMA_VERSION,),
)
conn.commit()
def _row_to_dict(row: sqlite3.Row) -> Dict[str, Any]:
return {
"id": row["id"],
"normalized": row["normalized"],
"token": row["token"],
"language": row["language"],
"pronunciation": row["pronunciation"],
"voice": row["voice"],
"notes": row["notes"],
"context": row["context"],
"usage_count": row["usage_count"],
"created_at": row["created_at"],
"updated_at": row["updated_at"],
}
def load_overrides(language: str, tokens: Iterable[str]) -> Dict[str, Dict[str, Any]]:
normalized_tokens = {normalize_token(token) for token in tokens if token}
if not normalized_tokens:
return {}
placeholders = ",".join("?" for _ in normalized_tokens)
with _DB_LOCK:
conn = _connect()
try:
_ensure_schema(conn)
conn.row_factory = sqlite3.Row
cursor = conn.execute(
f"SELECT * FROM overrides WHERE language=? AND normalized IN ({placeholders})",
(language, *normalized_tokens),
)
results: Dict[str, Dict[str, Any]] = {}
for row in cursor.fetchall():
payload = _row_to_dict(row)
results[payload["normalized"]] = payload
return results
finally:
conn.close()
def search_overrides(language: str, query: str, *, limit: int = 15) -> List[Dict[str, Any]]:
if not query:
return []
pattern = f"%{query.lower()}%"
with _DB_LOCK:
conn = _connect()
try:
_ensure_schema(conn)
conn.row_factory = sqlite3.Row
cursor = conn.execute(
"""
SELECT * FROM overrides
WHERE language = ? AND (normalized LIKE ? OR LOWER(token) LIKE ?)
ORDER BY usage_count DESC, updated_at DESC
LIMIT ?
""",
(language, pattern, pattern, limit),
)
return [_row_to_dict(row) for row in cursor.fetchall()]
finally:
conn.close()
def save_override(
*,
language: str,
token: str,
pronunciation: Optional[str] = None,
voice: Optional[str] = None,
notes: Optional[str] = None,
context: Optional[str] = None,
) -> Dict[str, Any]:
normalized = normalize_token(token)
if not normalized:
raise ValueError("Provide a token to override")
timestamp = time.time()
with _DB_LOCK:
conn = _connect()
try:
_ensure_schema(conn)
conn.execute(
"""
INSERT INTO overrides (normalized, token, language, pronunciation, voice, notes, context, usage_count, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?)
ON CONFLICT(normalized, language) DO UPDATE SET
token=excluded.token,
pronunciation=excluded.pronunciation,
voice=excluded.voice,
notes=excluded.notes,
context=excluded.context,
updated_at=excluded.updated_at
""",
(
normalized,
token,
language,
pronunciation,
voice,
notes,
context,
timestamp,
timestamp,
),
)
conn.commit()
conn.row_factory = sqlite3.Row
row = conn.execute(
"SELECT * FROM overrides WHERE normalized=? AND language=?",
(normalized, language),
).fetchone()
if row is None: # pragma: no cover - defensive guard
raise RuntimeError("Override save failed")
return _row_to_dict(row)
finally:
conn.close()
def delete_override(*, language: str, token: str) -> None:
normalized = normalize_token(token)
if not normalized:
return
with _DB_LOCK:
conn = _connect()
try:
_ensure_schema(conn)
conn.execute("DELETE FROM overrides WHERE normalized=? AND language=?", (normalized, language))
conn.commit()
finally:
conn.close()
def all_overrides(language: str) -> List[Dict[str, Any]]:
with _DB_LOCK:
conn = _connect()
try:
_ensure_schema(conn)
conn.row_factory = sqlite3.Row
cursor = conn.execute(
"SELECT * FROM overrides WHERE language=? ORDER BY updated_at DESC",
(language,),
)
return [_row_to_dict(row) for row in cursor.fetchall()]
finally:
conn.close()
def increment_usage(*, language: str, token: str, amount: int = 1) -> None:
normalized = normalize_token(token)
if not normalized:
return
with _DB_LOCK:
conn = _connect()
try:
_ensure_schema(conn)
conn.execute(
"UPDATE overrides SET usage_count = usage_count + ?, updated_at = ? WHERE normalized=? AND language=?",
(amount, time.time(), normalized, language),
)
conn.commit()
finally:
conn.close()
+430 -25
View File
@@ -50,6 +50,18 @@ from abogen.utils import (
load_numpy_kpipeline, load_numpy_kpipeline,
save_config, 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 ( from abogen.voice_profiles import (
delete_profile, delete_profile,
duplicate_profile, duplicate_profile,
@@ -958,6 +970,318 @@ def _prepare_speaker_metadata(
return chunk_list, roster, analysis_payload, applied_languages, updated_config 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( def _apply_prepare_form(
pending: PendingJob, form: Mapping[str, Any] pending: PendingJob, form: Mapping[str, Any]
) -> tuple[ ) -> tuple[
@@ -1141,6 +1465,8 @@ def _apply_prepare_form(
enabled_overrides = [entry for entry in overrides if entry.get("enabled")] enabled_overrides = [entry for entry in overrides if entry.get("enabled")]
_sync_pronunciation_overrides(pending)
return ( return (
chunk_level_literal, chunk_level_literal,
overrides, overrides,
@@ -1261,6 +1587,13 @@ def _service() -> ConversionService:
return current_app.extensions["conversion_service"] 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]]: def _build_voice_catalog() -> List[Dict[str, str]]:
catalog: List[Dict[str, str]] = [] catalog: List[Dict[str, str]] = []
gender_map = {"f": "Female", "m": "Male"} gender_map = {"f": "Female", "m": "Male"}
@@ -2101,6 +2434,74 @@ def api_speaker_preview() -> ResponseReturnValue:
return response 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") @web_bp.post("/jobs")
def enqueue_job() -> ResponseReturnValue: def enqueue_job() -> ResponseReturnValue:
service = _service() service = _service()
@@ -2310,6 +2711,8 @@ def enqueue_job() -> ResponseReturnValue:
analysis_requested=initial_analysis, analysis_requested=initial_analysis,
) )
_refresh_entity_summary(pending, pending.chapters)
service.store_pending_job(pending) service.store_pending_job(pending)
pending.applied_speaker_config = selected_speaker_config or None pending.applied_speaker_config = selected_speaker_config or None
if config_languages: if config_languages:
@@ -2323,20 +2726,14 @@ def enqueue_job() -> ResponseReturnValue:
@web_bp.get("/jobs/prepare/<pending_id>") @web_bp.get("/jobs/prepare/<pending_id>")
def prepare_job(pending_id: str) -> str: def prepare_job(pending_id: str) -> str:
pending = _service().get_pending_job(pending_id) pending = _require_pending_job(pending_id)
if not pending:
abort(404)
pending = cast(PendingJob, pending)
return _render_prepare_page(pending, active_step="chapters") return _render_prepare_page(pending, active_step="chapters")
@web_bp.post("/jobs/prepare/<pending_id>/analyze") @web_bp.post("/jobs/prepare/<pending_id>/analyze")
def analyze_pending_job(pending_id: str) -> ResponseReturnValue: def analyze_pending_job(pending_id: str) -> ResponseReturnValue:
service = _service() service = _service()
pending = service.get_pending_job(pending_id) pending = _require_pending_job(pending_id)
if not pending:
abort(404)
pending = cast(PendingJob, pending)
( (
chunk_level_literal, chunk_level_literal,
@@ -2412,21 +2809,20 @@ def analyze_pending_job(pending_id: str) -> ResponseReturnValue:
if selected_total: if selected_total:
pending.total_characters = selected_total pending.total_characters = selected_total
_sync_pronunciation_overrides(pending)
service.store_pending_job(pending) service.store_pending_job(pending)
notice_message = "Speaker analysis updated." notice_message = "Entity insights updated."
if persist_config_requested and config_name: if persist_config_requested and config_name:
notice_message = "Speaker analysis updated and configuration saved." notice_message = "Entity insights updated and configuration saved."
return _render_prepare_page(pending, notice=notice_message, active_step="speakers") return _render_prepare_page(pending, notice=notice_message, active_step="entities")
@web_bp.post("/jobs/prepare/<pending_id>") @web_bp.post("/jobs/prepare/<pending_id>")
def finalize_job(pending_id: str) -> ResponseReturnValue: def finalize_job(pending_id: str) -> ResponseReturnValue:
service = _service() service = _service()
pending = service.get_pending_job(pending_id) pending = _require_pending_job(pending_id)
if not pending:
abort(404)
pending = cast(PendingJob, pending)
( (
chunk_level_literal, chunk_level_literal,
@@ -2443,7 +2839,7 @@ def finalize_job(pending_id: str) -> ResponseReturnValue:
return _render_prepare_page( return _render_prepare_page(
pending, pending,
error=" ".join(errors), 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": 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() 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) raw_chunks = build_chunks_for_chapters(enabled_overrides, level=chunk_level_literal)
analysis_chunks = build_chunks_for_chapters(enabled_overrides, level="sentence") analysis_chunks = build_chunks_for_chapters(enabled_overrides, level="sentence")
is_multi = pending.speaker_mode == "multi" is_multi = pending.speaker_mode == "multi"
analysis_requested = bool(getattr(pending, "analysis_requested", False)) 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: if analysis_requested:
existing_roster: Optional[Mapping[str, Any]] = pending.speakers 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 config_name = pending.applied_speaker_config or selected_config
speaker_config_payload = get_config(config_name) if config_name else None 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( processed_chunks, roster, analysis_payload, config_languages, updated_config = _prepare_speaker_metadata(
chapters=enabled_overrides, chapters=enabled_overrides,
chunks=raw_chunks, chunks=raw_chunks,
@@ -2511,15 +2909,17 @@ def finalize_job(pending_id: str) -> ResponseReturnValue:
if selected_total: if selected_total:
pending.total_characters = selected_total pending.total_characters = selected_total
if should_force_speakers: _sync_pronunciation_overrides(pending)
notice_message = "Review speaker assignments before queuing."
if should_force_entities:
notice_message = "Review entity settings before queuing."
if persist_config_requested and config_key: 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) service.store_pending_job(pending)
return _render_prepare_page( return _render_prepare_page(
pending, pending,
notice=notice_message, notice=notice_message,
active_step="speakers", active_step="entities",
) )
total_characters = selected_total or pending.total_characters 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=pending.speaker_analysis,
speaker_analysis_threshold=pending.speaker_analysis_threshold, speaker_analysis_threshold=pending.speaker_analysis_threshold,
analysis_requested=getattr(pending, "analysis_requested", False), 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: if config_languages:
@@ -2620,14 +3023,16 @@ def _render_prepare_page(
) or "chapters" ) or "chapters"
normalized_step = (active_step or "chapters").strip().lower() 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" normalized_step = "chapters"
is_multi = pending.speaker_mode == "multi" 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" 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( return render_template(
template_name, template_name,
+28 -3
View File
@@ -24,7 +24,7 @@ def _create_set_event() -> threading.Event:
return event return event
STATE_VERSION = 5 STATE_VERSION = 6
_JOB_LOGGER = logging.getLogger("abogen.jobs") _JOB_LOGGER = logging.getLogger("abogen.jobs")
@@ -133,8 +133,9 @@ class Job:
analysis_requested: bool = False analysis_requested: bool = False
speaker_voice_languages: List[str] = field(default_factory=list) speaker_voice_languages: List[str] = field(default_factory=list)
applied_speaker_config: Optional[str] = None applied_speaker_config: Optional[str] = None
speaker_voice_languages: List[str] = field(default_factory=list) entity_summary: Dict[str, Any] = field(default_factory=dict)
applied_speaker_config: Optional[str] = None 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: def add_log(self, message: str, level: str = "info") -> None:
entry = JobLog(timestamp=time.time(), message=message, level=level) entry = JobLog(timestamp=time.time(), message=message, level=level)
@@ -197,6 +198,9 @@ class Job:
"analysis_requested": self.analysis_requested, "analysis_requested": self.analysis_requested,
"speaker_voice_languages": list(self.speaker_voice_languages), "speaker_voice_languages": list(self.speaker_voice_languages),
"applied_speaker_config": self.applied_speaker_config, "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 analysis_requested: bool = False
speaker_voice_languages: List[str] = field(default_factory=list) speaker_voice_languages: List[str] = field(default_factory=list)
applied_speaker_config: Optional[str] = None 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: class ConversionService:
@@ -311,6 +319,9 @@ class ConversionService:
speaker_analysis: Optional[Mapping[str, Any]] = None, speaker_analysis: Optional[Mapping[str, Any]] = None,
speaker_analysis_threshold: int = 3, speaker_analysis_threshold: int = 3,
analysis_requested: bool = False, 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:
job_id = uuid.uuid4().hex job_id = uuid.uuid4().hex
normalized_metadata = self._normalize_metadata_tags(metadata_tags) normalized_metadata = self._normalize_metadata_tags(metadata_tags)
@@ -354,6 +365,9 @@ class ConversionService:
speaker_analysis=dict(speaker_analysis or {}), speaker_analysis=dict(speaker_analysis or {}),
speaker_analysis_threshold=int(speaker_analysis_threshold or 3), speaker_analysis_threshold=int(speaker_analysis_threshold or 3),
analysis_requested=bool(analysis_requested), 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: with self._lock:
self._jobs[job_id] = job self._jobs[job_id] = job
@@ -505,6 +519,9 @@ class ConversionService:
speaker_analysis=job.speaker_analysis, speaker_analysis=job.speaker_analysis,
speaker_analysis_threshold=job.speaker_analysis_threshold, speaker_analysis_threshold=job.speaker_analysis_threshold,
analysis_requested=job.analysis_requested, 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) new_job.speaker_voice_languages = list(job.speaker_voice_languages)
@@ -727,6 +744,9 @@ class ConversionService:
"speaker_analysis": dict(job.speaker_analysis), "speaker_analysis": dict(job.speaker_analysis),
"speaker_analysis_threshold": job.speaker_analysis_threshold, "speaker_analysis_threshold": job.speaker_analysis_threshold,
"analysis_requested": job.analysis_requested, "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: def _persist_state(self) -> None:
@@ -844,6 +864,11 @@ class ConversionService:
payload.get("speaker_analysis_threshold", job.speaker_analysis_threshold or 3) payload.get("speaker_analysis_threshold", job.speaker_analysis_threshold or 3)
) )
job.analysis_requested = bool(payload.get("analysis_requested", job.analysis_requested)) 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() job.pause_event.set()
return job return job
+608 -1
View File
@@ -450,7 +450,7 @@ document.addEventListener("DOMContentLoaded", () => {
analysisButtons.forEach((button) => { analysisButtons.forEach((button) => {
button.addEventListener("click", () => { button.addEventListener("click", () => {
if (activeStepInput) { if (activeStepInput) {
activeStepInput.value = "speakers"; activeStepInput.value = "entities";
} }
}); });
}); });
@@ -1082,6 +1082,613 @@ document.addEventListener("DOMContentLoaded", () => {
renderVoiceList(elements); 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) => { form.addEventListener("click", (event) => {
const genderMenu = event.target.closest('[data-role="gender-menu"]'); const genderMenu = event.target.closest('[data-role="gender-menu"]');
const genderPill = event.target.closest('[data-role="gender-pill"]'); const genderPill = event.target.closest('[data-role="gender-pill"]');
+1 -1
View File
@@ -5,7 +5,7 @@
{% block content %} {% block content %}
<section class="card card--workflow"> <section class="card card--workflow">
<h1 class="card__title">Create a New Audiobook</h1> <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" data-role="upload-dropzone" tabindex="0" role="button" aria-label="Open upload settings or drop a manuscript file">
<div class="upload-card__dropzone-content"> <div class="upload-card__dropzone-content">
<span class="upload-card__icon" aria-hidden="true">&uarr;</span> <span class="upload-card__icon" aria-hidden="true">&uarr;</span>
@@ -42,9 +42,9 @@
{% set narrator_voice = options.voices[0] %} {% set narrator_voice = options.voices[0] %}
{% endif %} {% endif %}
{% set speed_display = '%.2f'|format(narrator_speed) %} {% 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 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 == 'speakers' 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 == 'speakers' else '' %} {% set step3_state = 'is-active' if modal_step == 'entities' else '' %}
<div class="modal" data-role="upload-modal" hidden> <div class="modal" data-role="upload-modal" hidden>
<div class="modal__overlay" data-role="upload-modal-close" tabindex="-1"></div> <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"> <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 %} {% if is_multi %}
<span class="step-indicator__item {{ step3_state }}"> <span class="step-indicator__item {{ step3_state }}">
<span class="step-indicator__index">3</span> <span class="step-indicator__index">3</span>
<span class="step-indicator__label">Speakers</span> <span class="step-indicator__label">Entities</span>
</span> </span>
{% endif %} {% endif %}
</nav> </nav>
@@ -177,7 +177,7 @@
</section> </section>
<section class="form-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="form-section__layout">
<div class="field-grid field-grid--two"> <div class="field-grid field-grid--two">
<div class="field field--stack"> <div class="field field--stack">
@@ -212,7 +212,7 @@
<div class="field"> <div class="field">
<label for="speaker_analysis_threshold">Speaker analysis minimum mentions</label> <label for="speaker_analysis_threshold">Speaker analysis minimum mentions</label>
<input type="number" min="1" max="25" id="speaker_analysis_threshold" name="speaker_analysis_threshold" value="{{ analysis_threshold_value }}" {{ 'disabled' if readonly else '' }}> <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> </div>
<div class="field"> <div class="field">
+4 -4
View File
@@ -27,15 +27,15 @@
</a> </a>
{% if is_multi_speaker %} {% if is_multi_speaker %}
<a class="step-indicator__item" <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__index">3</span>
<span class="step-indicator__label">Speakers</span> <span class="step-indicator__label">Entities</span>
</a> </a>
{% endif %} {% endif %}
</nav> </nav>
<p class="modal__eyebrow">Step 2 of {{ total_steps }}</p> <p class="modal__eyebrow">Step 2 of {{ total_steps }}</p>
<h2 class="modal__title" id="prepare-chapters-title">Select chapters</h2> <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>
<div class="wizard-card__aside"> <div class="wizard-card__aside">
<p class="wizard-card__filename" title="{{ pending.original_filename }}">{{ pending.original_filename }}</p> <p class="wizard-card__filename" title="{{ pending.original_filename }}">{{ pending.original_filename }}</p>
@@ -168,7 +168,7 @@
data-step-toggle="analysis" data-step-toggle="analysis"
formaction="{{ url_for('web.analyze_pending_job', pending_id=pending.id) }}" formaction="{{ url_for('web.analyze_pending_job', pending_id=pending.id) }}"
formmethod="post"> formmethod="post">
Continue to speakers Continue to entities
</button> </button>
{% endif %} {% endif %}
<button type="submit" class="button" data-step-toggle="finalize"{% if is_multi_speaker %} hidden aria-hidden="true"{% 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. {# 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. #} 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-voice="{{ speaker.voice_formula or selected_voice or pending.voice }}"
data-speed="{{ '%.2f'|format(pending.speed) }}" data-speed="{{ '%.2f'|format(pending.speed) }}"
data-use-gpu="{{ 'true' if pending.use_gpu else 'false' }}" data-use-gpu="{{ 'true' if pending.use_gpu else 'false' }}"
+152
View File
@@ -0,0 +1,152 @@
# Entities Step Overhaul Plan
## Requirements Recap
- Integrate part-of-speech (POS) tagging to detect proper nouns with better precision.
- Rename Step 3 of the wizard from **Speakers** to **Entities** everywhere (routes, templates, copy, JS).
- Introduce a sub-navigation immediately below the step indicators with three tabs: **People**, **Entities**, **Manual Overrides**.
- Populate tabs with appropriate data:
- **People**: characters with dialogue/speech evidence.
- **Entities**: non-person proper nouns (organizations, places, artefacts, etc.).
- **Manual Overrides**: user-added entries with search-driven selection, pronunciation editing, and voice assignment tools.
- Allow manual overrides to:
- Search for tokens present in the uploaded manuscript/EPUB.
- Configure pronunciations and pick a voice (defaulting to narrator voice).
- Trigger previews using the same audio preview logic as other steps.
- Provide voice selection dropdowns (with auto-generate, browse, clear, etc.) for People and Manual Override rows.
- Tighten extraction logic so only proper nouns surface (no "The", "That", etc.).
- Normalise detected names by removing titles ("Mr.", "Dr.") and possessives ("Bob's" -> "Bob").
- Retain expandable sample paragraphs for context ("Preview full text" pattern) in the People tab and wherever excerpts appear.
- Persist pronunciation overrides in a shared store so recurring entities automatically preload past settings.
- Apply pronunciation overrides to every preview request and final conversion so TTS always respects user inputs.
- Add a help page documenting phonetic spelling techniques (inspired by the CMU guide) and surface it via a contextual tooltip/icon inside Step 3.
## Additional Considerations & Assumptions
- POS tagging scope is English-only for the initial release; spaCy will process the manuscript once and cache results so repeated visits to Step 3 reuse the parsed doc.
- spaCy core is MIT-licensed while the bundled `en_core_web_sm` model is CC BY-SA 3.0; we must include attribution and ensure redistribution remains compliant with the share-alike terms when packaging the model.
- spaCy may surface unusual proper nouns (e.g., fantasy names); users can leave them unchanged or override as desired.
- Manual overrides should persist with the pending job so that they can influence subsequent steps and final conversion. We likely need to extend pending job JSON storage and final job payloads.
- People tab currently depends on `pending.speakers` generated in `speaker_analysis.py`. Re-architecting should avoid breaking existing downstream behaviour (e.g., queueing with selected voices).
- Entities tab is new; we need to decide what metadata to display (count, first occurrence, sample sentences) and how it affects conversion (e.g., optional pronunciations, tags?). For now, assume read-only insights with optional pronunciation overrides similar to People.
- Voice preview/generation flows already live in `prepare.js`; ensure refactors keep a single source of truth to avoid duplication.
## Linguistic & Data Strategy
1. **POS Tagging Research & Adoption**
- Leverage **spaCy** (>=3.5) for tokenisation, POS tagging, and named entity recognition (NER). It offers:
- Accurate POS tags for proper nouns (`PROPN`).
- Entity type labels (`PERSON`, `ORG`, `GPE`, etc.) that can help route to People vs Entities.
- Add `spacy` to dependencies and document model installation (`en_core_web_sm` minimum). Provide fallbacks:
- If model missing, prompt friendly error and skip advanced detection rather than failing job.
- Future extension: allow language-specific models per job language (English default, warn otherwise).
2. **Proper Noun Filtering Logic**
- Process each chapter/chunk through spaCy pipeline.
- For each token / entity:
- Keep tokens tagged `PROPN` or NER labelled as proper nouns.
- Discard stopwords and determiners even if mislabelled (helps avoid "The", "That").
- Normalise by removing leading titles (`Mr.`, `Dr.`, `Lady`, etc.) and trailing possessives (`'s`, `s`).
- Merge contiguous proper nouns into multi-word names (spaCy entity spans help).
- Build frequency map; attach contextual snippets (e.g., surrounding sentence) for each.
- Classify as Person vs Entity:
- If entity label `PERSON` or strongly associated with dialogue attribution (existing heuristics), treat as **Person**.
- Otherwise, map to **Entity**; optionally infer subtypes (Org, Place) for later enhancements.
3. **Integration with Existing Speaker Analysis**
- Reuse dialogue-based detection (`speaker_analysis.py`) for People to keep gender heuristics and sample quotes.
- Align IDs: ensure People tab entries map to existing speaker IDs so voice selections propagate to final job.
- Entities tab can draw from new data structure, decoupled from `speaker_analysis` but referencing chapter/chunk indices.
4. **Manual Overrides Workflow**
- Backend:
- Maintain `pending.manual_overrides` list containing `token`, `normalised_label`, `pronunciation`, `voice`, `notes`, `context`, while syncing to a persistent overrides table (e.g., SQLite) keyed by normalised token + language so history is reused across projects. Manual entries do not require spaCy detection—users can add arbitrary tokens.
- On load, hydrate the pending list with any matching historical overrides before rendering Step 3.
- Provide API endpoints:
1. `GET` suggestions for a search query (scan processed tokens + raw text indexes).
2. `POST` create/update override entries.
3. `DELETE` override.
- Frontend:
- Search input with debounced calls to suggestion endpoint; results list to choose target word/phrase.
- Once selected, show pronunciation input, voice picker (reusing component from People), preview buttons.
- Allow manual entry of custom tokens when no suggestion matches (spaCy not required).
- Persist changes via AJAX (same pattern as existing speaker updates if possible) or within form submission when continuing.
## Implementation Plan
1. **Backend Enhancements**
- Add spaCy dependency and lazy-load model in `speaker_analysis.py` or a new `entity_analysis.py` module.
- Cache parsed spaCy documents per pending job (disk-backed or memoized) so repeated analysis reuses existing results without reprocessing the manuscript.
- Implement `extract_entities(chapters, language, config)` returning structure:
```python
{
"people": [
{"id": "speaker_1", "label": "Bob", "count": 12, "samples": [...], ...}
],
"entities": [
{"id": "entity_1", "label": "Starfleet", "kind": "ORG", "count": 5, "snippets": [...]}
],
"index": {...} # for search/autocomplete
}
```
- Enhance normalisation function to strip titles/possessives and collapse whitespace/diacritics consistently.
- Integrate entity output into pending job serialization so Step 3 view can render tabs without recomputation.
- Update job finalisation logic to include manual overrides and entity-derived metadata (for future TTS improvements).
- Introduce a persistent pronunciation overrides repository (SQLite via SQLAlchemy layer) shared across jobs/instances, with migrations and CRUD helpers.
- Apply pronunciation overrides to preview/conversion pipelines by substituting text prior to TTS synthesis (covering narrator defaults, People tab assignments, Entities tab items, and manual overrides on every TTS run).
2. **Template & UI Updates**
- Rename Step 3 to **Entities** in all templates (`prepare_speakers.html`, upload modal partial, step indicator macros).
- Refactor `prepare_speakers.html` to:
- Wrap content in tabbed interface (likely `<div role="tablist">` + panels).
- Tab panels:
1. **People**: existing speaker list; adjust headings and copy.
2. **Entities**: new list/grid showing non-person entities with counts and sample context; include optional pronunciation/voice controls if relevant.
3. **Manual Overrides**: search box, selected override editing form, table of current overrides.
- Ensure sample paragraphs remain behind a collapsible disclosure control (link + `<details>` as today).
- Place a help icon near pronunciation inputs; focusing/hovering reveals tooltip text summarising phonetic spelling tips and links to the full guide.
- Update CSS to style tabs consistent with modal aesthetic, including tooltip styling for the help icon.
- Add a dedicated phonetic spelling help page (e.g., `phonetic-pronunciation.html`) sourced from the CMU reference with attribution, linked from the tooltip and main help menu.
3. **Frontend Logic (`prepare.js`)**
- Introduce tab controller managing focus and ARIA attributes.
- Wire People tab voice dropdowns to existing preview logic; extend to manual overrides entries.
- Implement search suggestions for manual overrides (debounce, fetch, render list, handle selection).
- Ensure previews use existing `data-role="speaker-preview"` pipeline; extend dataset attributes as needed.
- Persist override edits either via hidden inputs or asynchronous saves; align with form submission semantics.
4. **APIs & Routing**
- Add Flask routes under `routes.py` or `web/service.py` for:
- `/pending/<id>/entities` (fetch processed entity data if not already included in template context).
- `/pending/<id>/overrides` (CRUD operations for manual overrides).
- Ensure permissions and CSRF tokens align with existing patterns.
5. **Data Persistence**
- Expand pending job model (likely stored in `queue_manager_gui.py` / `queued_item.py`) to keep:
- `entity_summary` snapshot (people/entities lists).
- `manual_overrides` list with user edits.
- Cached spaCy doc metadata (hash of source + serialized parse) to avoid reprocessing unchanged texts.
- Introduce persistent `pronunciation_overrides` table (SQLite) keyed by normalised token + language, storing pronunciation, preferred voice, notes, and usage metadata for reuse across projects.
- On finalise, merge overrides into job metadata so downstream conversion can honour pronunciations/voices and sync any changes back to the shared table.
6. **Testing Strategy**
- Unit tests for new normalisation and POS filtering functions (ensure "The", "That" excluded; "Bob's" normalised).
- Integration tests to confirm People tab still flows, manual overrides persist, Entities tab populates expected data.
- Add regression tests ensuring Step 3 rename does not break existing forms (e.g., `test_prepare_form.py`).
- Consider snapshot tests for API JSON structures.
- Add automated checks that pronunciation overrides apply to preview playback and conversion payloads for People and Entities entries alike.
7. **Documentation & Ops**
- Update README / docs with new Step 3 name and manual override instructions.
- Provide guidance for installing spaCy model (e.g., `python -m spacy download en_core_web_sm`).
- Document spaCy/model licensing obligations (MIT for core, CC BY-SA for small model) and add attribution in app credits/help page.
- Publish phonetic spelling help page content and link it from the tooltip/icon in Step 3 and support docs.
## Open Questions / Follow-Ups
- Should Entities tab allow voice assignments that influence TTS, or is it informational only? Yes, it should include voice assignments that influence TTS.
- Manual override search scope: entire text vs detected proper nouns? Current plan searches raw text and entity index.
- Performance: confirm caching strategy (e.g., store spaCy Doc pickles vs. rebuilding from serialized spans) to balance speed and storage.
## Next Steps
1. Validate spaCy dependency choice and licensing obligations (MIT core, CC BY-SA model) with stakeholders.
2. Finalise data contracts for entities, overrides, and the persistent pronunciation history schema.
3. Implement backend entity extraction, cached spaCy parsing, override hydration, and the TTS substitution pipeline.
4. Refactor frontend Step 3 UI with tabs, help icon/tooltip, and updated voice controls.
5. Build manual override search/edit UX wired to the shared overrides store and preview flow.
6. Update documentation (including phonetic guide) and expand automated tests.
+1
View File
@@ -17,6 +17,7 @@ dependencies = [
"misaki[zh]>=0.9.4", "misaki[zh]>=0.9.4",
"ebooklib>=0.19", "ebooklib>=0.19",
"beautifulsoup4>=4.13.4", "beautifulsoup4>=4.13.4",
"spacy>=3.5,<4.0",
"en-core-web-sm @ https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl", "en-core-web-sm @ https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl",
"PyMuPDF>=1.25.5", "PyMuPDF>=1.25.5",
"platformdirs>=4.3.7", "platformdirs>=4.3.7",