From 93fe48f6016a3edeb2a4f5d9bd62dcde5095b404 Mon Sep 17 00:00:00 2001 From: JB Date: Sat, 11 Oct 2025 14:14:19 -0700 Subject: [PATCH] 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. --- abogen/entity_analysis.py | 401 ++++++++++++ abogen/pronunciation_store.py | 228 +++++++ abogen/web/routes.py | 455 ++++++++++++- abogen/web/service.py | 31 +- abogen/web/static/prepare.js | 609 +++++++++++++++++- abogen/web/templates/index.html | 2 +- .../web/templates/partials/upload_modal.html | 12 +- abogen/web/templates/prepare_chapters.html | 8 +- abogen/web/templates/prepare_entities.html | 580 +++++++++++++++++ abogen/web/templates/prepare_job.html | 2 +- abogen/web/templates/prepare_speakers.html | 259 -------- docs/entities_step_overhaul_plan.md | 152 +++++ pyproject.toml | 1 + 13 files changed, 2440 insertions(+), 300 deletions(-) create mode 100644 abogen/entity_analysis.py create mode 100644 abogen/pronunciation_store.py create mode 100644 abogen/web/templates/prepare_entities.html create mode 100644 docs/entities_step_overhaul_plan.md diff --git a/abogen/entity_analysis.py b/abogen/entity_analysis.py new file mode 100644 index 0000000..d0256e4 --- /dev/null +++ b/abogen/entity_analysis.py @@ -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)) diff --git a/abogen/pronunciation_store.py b/abogen/pronunciation_store.py new file mode 100644 index 0000000..a6f0f4e --- /dev/null +++ b/abogen/pronunciation_store.py @@ -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() diff --git a/abogen/web/routes.py b/abogen/web/routes.py index 318022e..a9ee714 100644 --- a/abogen/web/routes.py +++ b/abogen/web/routes.py @@ -50,6 +50,18 @@ from abogen.utils import ( load_numpy_kpipeline, save_config, ) +from abogen.entity_analysis import ( + extract_entities, + merge_override, + normalize_token as normalize_entity_token, + search_tokens as search_entity_tokens, +) +from abogen.pronunciation_store import ( + delete_override as delete_pronunciation_override, + load_overrides as load_pronunciation_overrides, + save_override as save_pronunciation_override, + search_overrides as search_pronunciation_overrides, +) from abogen.voice_profiles import ( delete_profile, duplicate_profile, @@ -958,6 +970,318 @@ def _prepare_speaker_metadata( return chunk_list, roster, analysis_payload, applied_languages, updated_config +def _collect_pronunciation_overrides(pending: PendingJob) -> List[Dict[str, Any]]: + language = pending.language or "en" + collected: Dict[str, Dict[str, Any]] = {} + + summary = pending.entity_summary or {} + for group in ("people", "entities"): + entries = summary.get(group) + if not isinstance(entries, list): + continue + for entry in entries: + if not isinstance(entry, Mapping): + continue + override_payload = entry.get("override") + if not isinstance(override_payload, Mapping): + continue + token_value = str(entry.get("label") or override_payload.get("token") or "").strip() + pronunciation_value = str(override_payload.get("pronunciation") or "").strip() + if not token_value or not pronunciation_value: + continue + normalized = normalize_entity_token(entry.get("normalized") or token_value) + if not normalized: + continue + collected[normalized] = { + "token": token_value, + "normalized": normalized, + "pronunciation": pronunciation_value, + "voice": str(override_payload.get("voice") or "").strip() or None, + "notes": str(override_payload.get("notes") or "").strip() or None, + "context": str(override_payload.get("context") or "").strip() or None, + "source": f"{group}-override", + "language": language, + } + + if isinstance(pending.speakers, Mapping): + for speaker_payload in pending.speakers.values(): + if not isinstance(speaker_payload, Mapping): + continue + token_value = str(speaker_payload.get("label") or "").strip() + pronunciation_value = str(speaker_payload.get("pronunciation") or "").strip() + if not token_value or not pronunciation_value: + continue + normalized = normalize_entity_token(token_value) + if not normalized: + continue + collected[normalized] = { + "token": token_value, + "normalized": normalized, + "pronunciation": pronunciation_value, + "voice": str( + speaker_payload.get("resolved_voice") + or speaker_payload.get("voice") + or pending.voice + ).strip() + or None, + "notes": None, + "context": None, + "source": "speaker", + "language": language, + } + + for manual_entry in pending.manual_overrides or []: + if not isinstance(manual_entry, Mapping): + continue + token_value = str(manual_entry.get("token") or "").strip() + pronunciation_value = str(manual_entry.get("pronunciation") or "").strip() + if not token_value or not pronunciation_value: + continue + normalized = manual_entry.get("normalized") or normalize_entity_token(token_value) + if not normalized: + continue + collected[normalized] = { + "token": token_value, + "normalized": normalized, + "pronunciation": pronunciation_value, + "voice": str(manual_entry.get("voice") or "").strip() or None, + "notes": str(manual_entry.get("notes") or "").strip() or None, + "context": str(manual_entry.get("context") or "").strip() or None, + "source": str(manual_entry.get("source") or "manual"), + "language": language, + } + + return list(collected.values()) + + +def _sync_pronunciation_overrides(pending: PendingJob) -> None: + pending.pronunciation_overrides = _collect_pronunciation_overrides(pending) + + if not pending.pronunciation_overrides: + return + + summary = pending.entity_summary or {} + manual_map: Dict[str, Mapping[str, Any]] = {} + for override in pending.manual_overrides or []: + if not isinstance(override, Mapping): + continue + normalized = override.get("normalized") or normalize_entity_token(override.get("token") or "") + pronunciation_value = str(override.get("pronunciation") or "").strip() + if not normalized or not pronunciation_value: + continue + manual_map[normalized] = override + for group in ("people", "entities"): + entries = summary.get(group) + if not isinstance(entries, list): + continue + for entry in entries: + if not isinstance(entry, dict): + continue + normalized = normalize_entity_token(entry.get("normalized") or entry.get("label") or "") + manual_override = manual_map.get(normalized) + if manual_override: + entry["override"] = { + "token": manual_override.get("token"), + "pronunciation": manual_override.get("pronunciation"), + "voice": manual_override.get("voice"), + "notes": manual_override.get("notes"), + "context": manual_override.get("context"), + "source": manual_override.get("source"), + } + + +def _refresh_entity_summary(pending: PendingJob, chapters: Iterable[Mapping[str, Any]]) -> None: + language = pending.language or "en" + result = extract_entities(chapters, language=language) + summary = dict(result.summary) + tokens: List[str] = [] + for group in ("people", "entities"): + entries = summary.get(group) + if not isinstance(entries, list): + continue + for entry in entries: + if not isinstance(entry, Mapping): + continue + token_value = str(entry.get("normalized") or entry.get("label") or "").strip() + if token_value: + tokens.append(token_value) + + overrides_from_store = load_pronunciation_overrides(language=language, tokens=tokens) + merged_summary = merge_override(summary, overrides_from_store) + if result.errors: + merged_summary["errors"] = list(result.errors) + merged_summary["cache_key"] = result.cache_key + pending.entity_summary = merged_summary + pending.entity_cache_key = result.cache_key + _sync_pronunciation_overrides(pending) + + +def _find_manual_override(pending: PendingJob, identifier: str) -> Optional[Dict[str, Any]]: + for entry in pending.manual_overrides or []: + if not isinstance(entry, dict): + continue + if entry.get("id") == identifier or entry.get("normalized") == identifier: + return entry + return None + + +def _upsert_manual_override(pending: PendingJob, payload: Mapping[str, Any]) -> Dict[str, Any]: + token_value = str(payload.get("token") or "").strip() + if not token_value: + raise ValueError("Token is required") + pronunciation_value = str(payload.get("pronunciation") or "").strip() + voice_value = str(payload.get("voice") or "").strip() + notes_value = str(payload.get("notes") or "").strip() + context_value = str(payload.get("context") or "").strip() + normalized = payload.get("normalized") or normalize_entity_token(token_value) + if not normalized: + raise ValueError("Token is required") + + existing = _find_manual_override(pending, payload.get("id", "")) or _find_manual_override(pending, normalized) + timestamp = time.time() + language = pending.language or "en" + + if existing: + existing.update( + { + "token": token_value, + "normalized": normalized, + "pronunciation": pronunciation_value, + "voice": voice_value, + "notes": notes_value, + "context": context_value, + "updated_at": timestamp, + } + ) + manual_entry = existing + else: + manual_entry = { + "id": payload.get("id") or uuid.uuid4().hex, + "token": token_value, + "normalized": normalized, + "pronunciation": pronunciation_value, + "voice": voice_value, + "notes": notes_value, + "context": context_value, + "language": language, + "source": payload.get("source") or "manual", + "created_at": timestamp, + "updated_at": timestamp, + } + if isinstance(pending.manual_overrides, list): + pending.manual_overrides.append(manual_entry) + else: + pending.manual_overrides = [manual_entry] + + save_pronunciation_override( + language=language, + token=token_value, + pronunciation=pronunciation_value or None, + voice=voice_value or None, + notes=notes_value or None, + context=context_value or None, + ) + + _sync_pronunciation_overrides(pending) + return dict(manual_entry) + + +def _delete_manual_override(pending: PendingJob, override_id: str) -> bool: + if not override_id: + return False + entries = pending.manual_overrides or [] + for index, entry in enumerate(entries): + if not isinstance(entry, dict): + continue + if entry.get("id") == override_id: + token_value = entry.get("token") or "" + language = pending.language or "en" + delete_pronunciation_override(language=language, token=token_value) + entries.pop(index) + pending.manual_overrides = entries + _sync_pronunciation_overrides(pending) + return True + return False + + +def _search_manual_override_candidates(pending: PendingJob, query: str, *, limit: int = 15) -> List[Dict[str, Any]]: + normalized_query = (query or "").strip() + summary_index = (pending.entity_summary or {}).get("index", {}) + matches = search_entity_tokens(summary_index, normalized_query, limit=limit) + registry: Dict[str, Dict[str, Any]] = {} + + for entry in matches: + normalized = normalize_entity_token(entry.get("normalized") or entry.get("token") or "") + if not normalized: + continue + registry.setdefault( + normalized, + { + "token": entry.get("token"), + "normalized": normalized, + "category": entry.get("category") or "entity", + "count": entry.get("count", 0), + "samples": entry.get("samples", []), + "source": "entity", + }, + ) + + language = pending.language or "en" + store_matches = search_pronunciation_overrides(language=language, query=normalized_query, limit=limit) + for entry in store_matches: + normalized = entry.get("normalized") + if not normalized: + continue + registry.setdefault( + normalized, + { + "token": entry.get("token"), + "normalized": normalized, + "category": "history", + "count": entry.get("usage_count", 0), + "samples": [entry.get("context")] if entry.get("context") else [], + "source": "history", + "pronunciation": entry.get("pronunciation"), + "voice": entry.get("voice"), + }, + ) + + for entry in pending.manual_overrides or []: + if not isinstance(entry, Mapping): + continue + normalized = entry.get("normalized") + if not normalized: + continue + registry.setdefault( + normalized, + { + "token": entry.get("token"), + "normalized": normalized, + "category": "manual", + "count": 0, + "samples": [entry.get("context")] if entry.get("context") else [], + "source": "manual", + "pronunciation": entry.get("pronunciation"), + "voice": entry.get("voice"), + }, + ) + + ordered = sorted(registry.values(), key=lambda item: (-int(item.get("count") or 0), item.get("token") or "")) + if limit: + return ordered[:limit] + return ordered + + +def _pending_entities_payload(pending: PendingJob) -> Dict[str, Any]: + return { + "summary": pending.entity_summary or {}, + "manual_overrides": pending.manual_overrides or [], + "pronunciation_overrides": pending.pronunciation_overrides or [], + "cache_key": pending.entity_cache_key, + "language": pending.language or "en", + } + + def _apply_prepare_form( pending: PendingJob, form: Mapping[str, Any] ) -> tuple[ @@ -1141,6 +1465,8 @@ def _apply_prepare_form( enabled_overrides = [entry for entry in overrides if entry.get("enabled")] + _sync_pronunciation_overrides(pending) + return ( chunk_level_literal, overrides, @@ -1261,6 +1587,13 @@ def _service() -> ConversionService: return current_app.extensions["conversion_service"] +def _require_pending_job(pending_id: str) -> PendingJob: + pending = _service().get_pending_job(pending_id) + if not pending: + abort(404) + return cast(PendingJob, pending) + + def _build_voice_catalog() -> List[Dict[str, str]]: catalog: List[Dict[str, str]] = [] gender_map = {"f": "Female", "m": "Male"} @@ -2101,6 +2434,74 @@ def api_speaker_preview() -> ResponseReturnValue: return response +@api_bp.get("/pending//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//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//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//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//manual-overrides/") +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//manual-overrides/search") +def api_search_manual_override_candidates(pending_id: str) -> ResponseReturnValue: + pending = _require_pending_job(pending_id) + query = (request.args.get("q") or request.args.get("query") or "").strip() + limit_param = request.args.get("limit") + limit_value = _coerce_int(limit_param, 15, minimum=1, maximum=50) if limit_param is not None else 15 + results = _search_manual_override_candidates(pending, query, limit=limit_value) + return jsonify({"query": query, "limit": limit_value, "results": results}) + + @web_bp.post("/jobs") def enqueue_job() -> ResponseReturnValue: service = _service() @@ -2310,6 +2711,8 @@ def enqueue_job() -> ResponseReturnValue: analysis_requested=initial_analysis, ) + _refresh_entity_summary(pending, pending.chapters) + service.store_pending_job(pending) pending.applied_speaker_config = selected_speaker_config or None if config_languages: @@ -2323,20 +2726,14 @@ def enqueue_job() -> ResponseReturnValue: @web_bp.get("/jobs/prepare/") def prepare_job(pending_id: str) -> str: - pending = _service().get_pending_job(pending_id) - if not pending: - abort(404) - pending = cast(PendingJob, pending) + pending = _require_pending_job(pending_id) return _render_prepare_page(pending, active_step="chapters") @web_bp.post("/jobs/prepare//analyze") def analyze_pending_job(pending_id: str) -> ResponseReturnValue: service = _service() - pending = service.get_pending_job(pending_id) - if not pending: - abort(404) - pending = cast(PendingJob, pending) + pending = _require_pending_job(pending_id) ( chunk_level_literal, @@ -2412,21 +2809,20 @@ def analyze_pending_job(pending_id: str) -> ResponseReturnValue: if selected_total: pending.total_characters = selected_total + _sync_pronunciation_overrides(pending) + service.store_pending_job(pending) - notice_message = "Speaker analysis updated." + notice_message = "Entity insights updated." if persist_config_requested and config_name: - notice_message = "Speaker analysis updated and configuration saved." - return _render_prepare_page(pending, notice=notice_message, active_step="speakers") + notice_message = "Entity insights updated and configuration saved." + return _render_prepare_page(pending, notice=notice_message, active_step="entities") @web_bp.post("/jobs/prepare/") def finalize_job(pending_id: str) -> ResponseReturnValue: service = _service() - pending = service.get_pending_job(pending_id) - if not pending: - abort(404) - pending = cast(PendingJob, pending) + pending = _require_pending_job(pending_id) ( chunk_level_literal, @@ -2443,7 +2839,7 @@ def finalize_job(pending_id: str) -> ResponseReturnValue: return _render_prepare_page( pending, error=" ".join(errors), - active_step=request.form.get("active_step") or "speakers", + active_step=request.form.get("active_step") or "entities", ) if pending.speaker_mode != "multi": @@ -2458,12 +2854,14 @@ def finalize_job(pending_id: str) -> ResponseReturnValue: ) active_step = (request.form.get("active_step") or "chapters").strip().lower() + if active_step == "speakers": + active_step = "entities" raw_chunks = build_chunks_for_chapters(enabled_overrides, level=chunk_level_literal) analysis_chunks = build_chunks_for_chapters(enabled_overrides, level="sentence") is_multi = pending.speaker_mode == "multi" analysis_requested = bool(getattr(pending, "analysis_requested", False)) - should_force_speakers = is_multi and active_step != "speakers" + should_force_entities = is_multi and active_step != "entities" if analysis_requested: existing_roster: Optional[Mapping[str, Any]] = pending.speakers @@ -2477,7 +2875,7 @@ def finalize_job(pending_id: str) -> ResponseReturnValue: config_name = pending.applied_speaker_config or selected_config speaker_config_payload = get_config(config_name) if config_name else None - run_analysis = is_multi and (should_force_speakers or analysis_requested) + run_analysis = is_multi and (should_force_entities or analysis_requested) processed_chunks, roster, analysis_payload, config_languages, updated_config = _prepare_speaker_metadata( chapters=enabled_overrides, chunks=raw_chunks, @@ -2511,15 +2909,17 @@ def finalize_job(pending_id: str) -> ResponseReturnValue: if selected_total: pending.total_characters = selected_total - if should_force_speakers: - notice_message = "Review speaker assignments before queuing." + _sync_pronunciation_overrides(pending) + + if should_force_entities: + notice_message = "Review entity settings before queuing." if persist_config_requested and config_key: - notice_message = "Configuration saved. Review speaker assignments before queuing." + notice_message = "Configuration saved. Review entity settings before queuing." service.store_pending_job(pending) return _render_prepare_page( pending, notice=notice_message, - active_step="speakers", + active_step="entities", ) total_characters = selected_total or pending.total_characters @@ -2559,6 +2959,9 @@ def finalize_job(pending_id: str) -> ResponseReturnValue: speaker_analysis=pending.speaker_analysis, speaker_analysis_threshold=pending.speaker_analysis_threshold, analysis_requested=getattr(pending, "analysis_requested", False), + entity_summary=pending.entity_summary, + manual_overrides=pending.manual_overrides, + pronunciation_overrides=pending.pronunciation_overrides, ) if config_languages: @@ -2620,14 +3023,16 @@ def _render_prepare_page( ) or "chapters" normalized_step = (active_step or "chapters").strip().lower() - if normalized_step not in {"chapters", "speakers"}: + if normalized_step == "speakers": + normalized_step = "entities" + if normalized_step not in {"chapters", "entities"}: normalized_step = "chapters" is_multi = pending.speaker_mode == "multi" - if normalized_step == "speakers" and not is_multi: + if normalized_step == "entities" and not is_multi: normalized_step = "chapters" - template_name = "prepare_speakers.html" if normalized_step == "speakers" else "prepare_chapters.html" + template_name = "prepare_entities.html" if normalized_step == "entities" else "prepare_chapters.html" return render_template( template_name, diff --git a/abogen/web/service.py b/abogen/web/service.py index f21e45b..2030760 100644 --- a/abogen/web/service.py +++ b/abogen/web/service.py @@ -24,7 +24,7 @@ def _create_set_event() -> threading.Event: return event -STATE_VERSION = 5 +STATE_VERSION = 6 _JOB_LOGGER = logging.getLogger("abogen.jobs") @@ -133,8 +133,9 @@ class Job: analysis_requested: bool = False speaker_voice_languages: List[str] = field(default_factory=list) applied_speaker_config: Optional[str] = None - speaker_voice_languages: List[str] = field(default_factory=list) - applied_speaker_config: Optional[str] = None + entity_summary: Dict[str, Any] = field(default_factory=dict) + manual_overrides: List[Dict[str, Any]] = field(default_factory=list) + pronunciation_overrides: List[Dict[str, Any]] = field(default_factory=list) def add_log(self, message: str, level: str = "info") -> None: entry = JobLog(timestamp=time.time(), message=message, level=level) @@ -197,6 +198,9 @@ class Job: "analysis_requested": self.analysis_requested, "speaker_voice_languages": list(self.speaker_voice_languages), "applied_speaker_config": self.applied_speaker_config, + "entity_summary": dict(self.entity_summary), + "manual_overrides": [dict(entry) for entry in self.manual_overrides], + "pronunciation_overrides": [dict(entry) for entry in self.pronunciation_overrides], } @@ -239,6 +243,10 @@ class PendingJob: analysis_requested: bool = False speaker_voice_languages: List[str] = field(default_factory=list) applied_speaker_config: Optional[str] = None + entity_summary: Dict[str, Any] = field(default_factory=dict) + manual_overrides: List[Dict[str, Any]] = field(default_factory=list) + pronunciation_overrides: List[Dict[str, Any]] = field(default_factory=list) + entity_cache_key: Optional[str] = None class ConversionService: @@ -311,6 +319,9 @@ class ConversionService: speaker_analysis: Optional[Mapping[str, Any]] = None, speaker_analysis_threshold: int = 3, analysis_requested: bool = False, + entity_summary: Optional[Mapping[str, Any]] = None, + manual_overrides: Optional[Iterable[Mapping[str, Any]]] = None, + pronunciation_overrides: Optional[Iterable[Mapping[str, Any]]] = None, ) -> Job: job_id = uuid.uuid4().hex normalized_metadata = self._normalize_metadata_tags(metadata_tags) @@ -354,6 +365,9 @@ class ConversionService: speaker_analysis=dict(speaker_analysis or {}), speaker_analysis_threshold=int(speaker_analysis_threshold or 3), analysis_requested=bool(analysis_requested), + entity_summary=dict(entity_summary or {}), + manual_overrides=[dict(entry) for entry in manual_overrides] if manual_overrides else [], + pronunciation_overrides=[dict(entry) for entry in pronunciation_overrides] if pronunciation_overrides else [], ) with self._lock: self._jobs[job_id] = job @@ -505,6 +519,9 @@ class ConversionService: speaker_analysis=job.speaker_analysis, speaker_analysis_threshold=job.speaker_analysis_threshold, analysis_requested=job.analysis_requested, + entity_summary=job.entity_summary, + manual_overrides=job.manual_overrides, + pronunciation_overrides=job.pronunciation_overrides, ) new_job.speaker_voice_languages = list(job.speaker_voice_languages) @@ -727,6 +744,9 @@ class ConversionService: "speaker_analysis": dict(job.speaker_analysis), "speaker_analysis_threshold": job.speaker_analysis_threshold, "analysis_requested": job.analysis_requested, + "entity_summary": dict(job.entity_summary), + "manual_overrides": [dict(entry) for entry in job.manual_overrides], + "pronunciation_overrides": [dict(entry) for entry in job.pronunciation_overrides], } def _persist_state(self) -> None: @@ -844,6 +864,11 @@ class ConversionService: payload.get("speaker_analysis_threshold", job.speaker_analysis_threshold or 3) ) job.analysis_requested = bool(payload.get("analysis_requested", job.analysis_requested)) + job.entity_summary = payload.get("entity_summary", {}) + job.manual_overrides = [dict(entry) for entry in payload.get("manual_overrides", []) if isinstance(entry, Mapping)] + job.pronunciation_overrides = [ + dict(entry) for entry in payload.get("pronunciation_overrides", []) if isinstance(entry, Mapping) + ] job.pause_event.set() return job diff --git a/abogen/web/static/prepare.js b/abogen/web/static/prepare.js index ba69004..1a44330 100644 --- a/abogen/web/static/prepare.js +++ b/abogen/web/static/prepare.js @@ -450,7 +450,7 @@ document.addEventListener("DOMContentLoaded", () => { analysisButtons.forEach((button) => { button.addEventListener("click", () => { if (activeStepInput) { - activeStepInput.value = "speakers"; + activeStepInput.value = "entities"; } }); }); @@ -1082,6 +1082,613 @@ document.addEventListener("DOMContentLoaded", () => { renderVoiceList(elements); } + const entitySummaryData = parseJSONScript("entity-summary-data") || {}; + const entityCacheKeyData = parseJSONScript("entity-cache-key"); + const manualOverridesSeed = parseJSONScript("manual-overrides-data") || []; + const pronunciationOverridesSeed = parseJSONScript("pronunciation-overrides-data") || []; + + const entityTabs = form.querySelector('[data-role="entity-tabs"]'); + const entitiesUrl = form.dataset.entitiesUrl || ""; + const manualUpsertUrl = form.dataset.manualUpsertUrl || ""; + const manualDeleteUrlTemplate = form.dataset.manualDeleteUrlTemplate || ""; + const manualSearchUrl = form.dataset.manualSearchUrl || ""; + const baseVoice = form.dataset.baseVoice || form.dataset.voice || ""; + const languageCode = form.dataset.language || "en"; + const defaultSpeed = form.dataset.speed || "1.0"; + const useGpuDefault = form.dataset.useGpu || "true"; + + const entityState = { + summary: entitySummaryData && typeof entitySummaryData === "object" ? entitySummaryData : {}, + cacheKey: typeof entityCacheKeyData === "string" ? entityCacheKeyData : "", + manualOverrides: Array.isArray(manualOverridesSeed) ? [...manualOverridesSeed] : [], + pronunciationOverrides: Array.isArray(pronunciationOverridesSeed) ? [...pronunciationOverridesSeed] : [], + }; + + let highlightedOverrideId = ""; + const pendingOverrideSaves = new Map(); + + if (entityTabs) { + const tabButtons = Array.from(entityTabs.querySelectorAll('[data-role="entity-tab"]')); + const tabPanels = new Map( + Array.from(entityTabs.querySelectorAll('[data-role="entity-panel"]')).map((panel) => [panel.dataset.panel || "", panel]) + ); + + const entitySummaryContainer = entityTabs.querySelector('[data-role="entity-summary"]'); + const entityStatsNode = entitySummaryContainer?.querySelector('[data-role="entity-stats"]'); + const entityListNode = entitySummaryContainer?.querySelector('[data-role="entity-list"]'); + const entityRowTemplate = entitySummaryContainer?.querySelector('template[data-role="entity-row-template"]'); + const entitiesRefreshButton = entitySummaryContainer?.querySelector('[data-role="entities-refresh"]'); + + const manualOverridesRoot = entityTabs.querySelector('[data-role="manual-overrides"]'); + const manualOverrideList = manualOverridesRoot?.querySelector('[data-role="manual-override-list"]'); + const manualOverrideTemplate = manualOverridesRoot?.querySelector('template[data-role="manual-override-template"]'); + const manualOverrideResultsList = manualOverridesRoot?.querySelector('[data-role="manual-override-results"]'); + const manualOverrideQueryInput = manualOverridesRoot?.querySelector('[data-role="manual-override-query"]'); + const manualOverrideSearchButton = manualOverridesRoot?.querySelector('[data-role="manual-override-search"]'); + const manualOverrideAddCustomButton = manualOverridesRoot?.querySelector('[data-role="manual-override-add-custom"]'); + const manualOverridesEmpty = manualOverridesRoot?.querySelector('[data-role="manual-overrides-empty"]'); + + const cloneTemplate = (template) => { + if (!template) return null; + if (template.content && template.content.firstElementChild) { + return template.content.firstElementChild.cloneNode(true); + } + return template.cloneNode(true); + }; + + const formatMentions = (value) => { + const count = Number(value || 0); + return `${count.toLocaleString()} mention${count === 1 ? "" : "s"}`; + }; + + function activateEntityTab(panelKey) { + tabButtons.forEach((button) => { + const isActive = button.dataset.panel === panelKey; + button.classList.toggle("is-active", isActive); + button.setAttribute("aria-selected", isActive ? "true" : "false"); + }); + tabPanels.forEach((panel, key) => { + if (!panel) return; + const isActive = key === panelKey; + panel.classList.toggle("is-active", isActive); + panel.hidden = !isActive; + panel.setAttribute("aria-hidden", isActive ? "false" : "true"); + }); + } + + function populateVoiceOptions(select, selectedVoice) { + if (!select) return; + const narratorLabel = baseVoice ? `Use narrator voice (${baseVoice})` : "Use narrator voice"; + select.innerHTML = ""; + const narratorOption = document.createElement("option"); + narratorOption.value = ""; + narratorOption.textContent = narratorLabel; + if (!selectedVoice) { + narratorOption.selected = true; + } + select.appendChild(narratorOption); + voiceCatalog.forEach((voice) => { + const option = document.createElement("option"); + option.value = voice.id; + option.textContent = `${voice.display_name} · ${voice.language_label} · ${voice.gender}`; + if (selectedVoice === voice.id) { + option.selected = true; + } + select.appendChild(option); + }); + if (selectedVoice) { + const hasMatch = Array.from(select.options).some((option) => option.value === selectedVoice); + if (!hasMatch) { + const fallback = document.createElement("option"); + fallback.value = selectedVoice; + fallback.textContent = selectedVoice; + fallback.selected = true; + select.appendChild(fallback); + } + } + } + + function renderEntitySummary() { + if (!entityListNode) return; + const summary = entityState.summary || {}; + const stats = summary.stats || {}; + const errors = Array.isArray(summary.errors) ? summary.errors : []; + if (entityStatsNode) { + if (errors.length) { + entityStatsNode.textContent = errors.join(" · "); + } else if (stats.processed) { + const parts = []; + if (typeof stats.chapters === "number") { + parts.push(`${stats.chapters} chapter${stats.chapters === 1 ? "" : "s"}`); + } + if (typeof stats.tokens === "number") { + parts.push(`${stats.tokens.toLocaleString()} tokens processed`); + } + entityStatsNode.textContent = parts.length ? parts.join(" · ") : "Entity analysis up to date."; + } else { + entityStatsNode.textContent = "Entity analysis will populate once you continue from chapters."; + } + } + + entityListNode.innerHTML = ""; + const entries = Array.isArray(summary.entities) ? summary.entities : []; + if (!entries.length) { + const emptyItem = document.createElement("li"); + emptyItem.className = "entity-summary__empty"; + emptyItem.textContent = "No entities detected yet."; + entityListNode.appendChild(emptyItem); + return; + } + + entries.slice(0, 120).forEach((entity) => { + const item = cloneTemplate(entityRowTemplate); + if (!item) return; + const normalized = entity.normalized || entity.label || entity.token || ""; + const tokenLabel = entity.label || entity.token || normalized || "Untitled entity"; + item.dataset.entityId = entity.id || normalized || tokenLabel; + + const labelEl = item.querySelector('[data-role="entity-label"]'); + if (labelEl) { + labelEl.textContent = tokenLabel; + } + + const kindEl = item.querySelector('[data-role="entity-kind"]'); + if (kindEl) { + const kind = entity.kind || entity.category || ""; + kindEl.textContent = kind; + kindEl.hidden = !kind; + } + + const countEl = item.querySelector('[data-role="entity-count"]'); + if (countEl) { + countEl.textContent = formatMentions(entity.count); + } + + const samplesContainer = item.querySelector('[data-role="entity-samples"]'); + if (samplesContainer) { + samplesContainer.innerHTML = ""; + const samples = Array.isArray(entity.samples) ? entity.samples : []; + if (!samples.length) { + const hint = document.createElement("p"); + hint.className = "hint"; + hint.textContent = "No sample sentences captured yet."; + samplesContainer.appendChild(hint); + } else { + const list = document.createElement("ul"); + list.className = "entity-summary__samples-list"; + samples.slice(0, 3).forEach((sample) => { + const text = typeof sample === "string" ? sample : sample?.excerpt; + if (!text) return; + const entry = document.createElement("li"); + entry.textContent = text; + list.appendChild(entry); + }); + samplesContainer.appendChild(list); + } + } + + const button = item.querySelector('[data-role="entity-add-override"]'); + if (button) { + const hasOverride = entityState.manualOverrides.some((entry) => { + const candidate = entry?.normalized || entry?.token || ""; + return candidate && candidate.toLowerCase() === normalized.toLowerCase(); + }); + button.dataset.entityToken = entity.label || entity.token || ""; + button.dataset.entityNormalized = normalized; + const sampleContext = Array.isArray(entity.samples) && entity.samples.length + ? typeof entity.samples[0] === "string" + ? entity.samples[0] + : entity.samples[0]?.excerpt || "" + : ""; + if (sampleContext) { + button.dataset.entityContext = sampleContext; + } + button.textContent = hasOverride ? "Edit manual override" : "Add manual override"; + } + + entityListNode.appendChild(item); + }); + } + + function renderManualOverrides() { + if (!manualOverrideList) return; + manualOverrideList.innerHTML = ""; + const overrides = Array.isArray(entityState.manualOverrides) ? entityState.manualOverrides : []; + if (!overrides.length) { + if (manualOverridesEmpty) { + manualOverridesEmpty.hidden = false; + } + return; + } + + if (manualOverridesEmpty) { + manualOverridesEmpty.hidden = true; + } + + overrides.forEach((override) => { + const node = cloneTemplate(manualOverrideTemplate); + if (!node) return; + const overrideId = override.id || override.normalized || override.token || ""; + node.dataset.overrideId = overrideId; + node.dataset.token = override.token || ""; + node.dataset.normalized = override.normalized || ""; + node.dataset.context = override.context || ""; + + const labelEl = node.querySelector('[data-role="override-label"]'); + if (labelEl) { + labelEl.textContent = override.token || override.normalized || "Manual override"; + } + + const notesEl = node.querySelector('[data-role="override-notes"]'); + if (notesEl) { + const notes = override.notes || override.context || ""; + if (notes) { + notesEl.textContent = notes; + } else { + notesEl.hidden = true; + } + } + + const pronInput = node.querySelector('[data-role="manual-override-pronunciation"]'); + if (pronInput) { + pronInput.value = override.pronunciation || ""; + pronInput.placeholder = override.token || override.normalized || ""; + pronInput.dataset.overrideId = overrideId; + } + + const voiceSelect = node.querySelector('[data-role="manual-override-voice"]'); + if (voiceSelect) { + populateVoiceOptions(voiceSelect, override.voice || ""); + voiceSelect.dataset.overrideId = overrideId; + } + + const previewButton = node.querySelector('[data-role="speaker-preview"]'); + if (previewButton) { + const previewVoice = override.voice || voiceSelect?.value || baseVoice || ""; + const previewText = override.pronunciation || override.token || ""; + previewButton.dataset.overrideId = overrideId; + previewButton.dataset.previewText = previewText; + previewButton.dataset.voice = previewVoice; + previewButton.dataset.language = languageCode; + previewButton.dataset.speed = defaultSpeed; + previewButton.dataset.useGpu = useGpuDefault; + } + + const deleteButton = node.querySelector('[data-role="manual-override-delete"]'); + if (deleteButton) { + deleteButton.dataset.overrideId = overrideId; + } + + const metaEl = node.querySelector('[data-role="manual-override-meta"]'); + if (metaEl) { + const parts = []; + if (override.source) { + parts.push(`Source: ${override.source}`); + } + if (override.updated_at) { + const timestamp = Number(override.updated_at) * 1000; + if (!Number.isNaN(timestamp)) { + parts.push(`Updated ${new Date(timestamp).toLocaleString()}`); + } + } + metaEl.textContent = parts.join(" · "); + } + + manualOverrideList.appendChild(node); + if (highlightedOverrideId && highlightedOverrideId === overrideId) { + node.classList.add("is-highlighted"); + setTimeout(() => node.classList.remove("is-highlighted"), 2400); + node.scrollIntoView({ behavior: "smooth", block: "center" }); + highlightedOverrideId = ""; + } + }); + } + + function applyEntityPayload(payload, options = {}) { + if (payload && typeof payload === "object") { + if (payload.summary) { + entityState.summary = payload.summary; + } + if (Array.isArray(payload.manual_overrides)) { + entityState.manualOverrides = payload.manual_overrides; + } + if (Array.isArray(payload.pronunciation_overrides)) { + entityState.pronunciationOverrides = payload.pronunciation_overrides; + } + if (typeof payload.cache_key === "string") { + entityState.cacheKey = payload.cache_key; + } + } + if (options.highlightId) { + highlightedOverrideId = options.highlightId; + } + renderEntitySummary(); + renderManualOverrides(); + } + + function collectOverridePayload(overrideId) { + if (!overrideId || !manualOverrideList) return null; + const selectorId = typeof CSS !== "undefined" && CSS.escape ? CSS.escape(overrideId) : overrideId.replace(/["\\]/g, "\\$&"); + const node = manualOverrideList.querySelector(`[data-override-id="${selectorId}"]`); + if (!node) return null; + const pronInput = node.querySelector('[data-role="manual-override-pronunciation"]'); + const voiceSelect = node.querySelector('[data-role="manual-override-voice"]'); + return { + id: overrideId, + token: node.dataset.token || "", + normalized: node.dataset.normalized || "", + pronunciation: pronInput?.value?.trim() || "", + voice: voiceSelect?.value || "", + notes: "", + context: node.dataset.context || "", + }; + } + + async function saveManualOverride(overrideId) { + const payload = collectOverridePayload(overrideId); + if (!payload || !manualUpsertUrl) return; + try { + const response = await fetch(manualUpsertUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (!response.ok) { + throw new Error(`Override save failed with status ${response.status}`); + } + const data = await response.json(); + applyEntityPayload(data, { highlightId: data.override?.id || payload.id }); + } catch (error) { + console.error("Failed to save manual override", error); + } + } + + function scheduleManualOverrideSave(overrideId) { + if (!overrideId) return; + if (pendingOverrideSaves.has(overrideId)) { + clearTimeout(pendingOverrideSaves.get(overrideId)); + } + const timer = setTimeout(() => { + pendingOverrideSaves.delete(overrideId); + saveManualOverride(overrideId); + }, 400); + pendingOverrideSaves.set(overrideId, timer); + } + + async function deleteManualOverride(overrideId) { + if (!overrideId || !manualDeleteUrlTemplate) return; + const targetUrl = manualDeleteUrlTemplate.replace("__OVERRIDE_ID__", encodeURIComponent(overrideId)); + try { + const response = await fetch(targetUrl, { method: "DELETE" }); + if (!response.ok) { + throw new Error(`Override delete failed with status ${response.status}`); + } + const data = await response.json(); + applyEntityPayload(data); + } catch (error) { + console.error("Failed to delete manual override", error); + } + } + + async function performEntitiesRefresh(force = false) { + if (!entitiesUrl) return; + try { + const url = new URL(entitiesUrl, window.location.origin); + if (force) { + url.searchParams.set("refresh", "1"); + } + if (entityState.cacheKey) { + url.searchParams.set("cache_key", entityState.cacheKey); + } + const response = await fetch(url.toString(), { method: "GET" }); + if (!response.ok) { + throw new Error(`Entity refresh failed with status ${response.status}`); + } + const data = await response.json(); + applyEntityPayload(data); + } catch (error) { + console.error("Failed to refresh entity summary", error); + } + } + + async function performManualOverrideSearch(query) { + if (!manualSearchUrl || !manualOverrideResultsList) return; + manualOverrideResultsList.innerHTML = ""; + try { + const url = new URL(manualSearchUrl, window.location.origin); + if (query) { + url.searchParams.set("q", query); + } + const response = await fetch(url.toString(), { method: "GET" }); + if (!response.ok) { + throw new Error(`Search failed with status ${response.status}`); + } + const data = await response.json(); + const results = Array.isArray(data.results) ? data.results : []; + if (!results.length) { + const emptyItem = document.createElement("li"); + emptyItem.className = "manual-overrides__results-empty"; + emptyItem.textContent = query ? "No matches found." : "Start typing to search tokens."; + manualOverrideResultsList.appendChild(emptyItem); + return; + } + results.forEach((entry) => { + const item = document.createElement("li"); + item.className = "manual-overrides__result"; + const button = document.createElement("button"); + button.type = "button"; + button.className = "button button--ghost button--small"; + button.dataset.role = "manual-override-result"; + button.dataset.token = entry.token || entry.normalized || ""; + if (entry.normalized) { + button.dataset.normalized = entry.normalized; + } + if (entry.context) { + button.dataset.context = entry.context; + } else if (Array.isArray(entry.samples) && entry.samples.length) { + const sample = entry.samples[0]; + button.dataset.context = typeof sample === "string" ? sample : sample?.excerpt || ""; + } + if (entry.pronunciation) { + button.dataset.pronunciation = entry.pronunciation; + } + if (entry.voice) { + button.dataset.voice = entry.voice; + } + button.dataset.source = entry.source || "search"; + button.textContent = entry.token || entry.normalized || "Unnamed token"; + item.appendChild(button); + manualOverrideResultsList.appendChild(item); + }); + } catch (error) { + console.error("Failed to search manual override candidates", error); + } + } + + async function createOverrideFromData(data) { + if (!data || !manualUpsertUrl) return; + const token = (data.token || "").trim(); + if (!token) return; + const payload = { + token, + normalized: (data.normalized || "").trim(), + context: data.context || "", + pronunciation: data.pronunciation || "", + voice: data.voice || "", + source: data.source || "manual", + }; + try { + const response = await fetch(manualUpsertUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (!response.ok) { + throw new Error(`Override creation failed with status ${response.status}`); + } + const body = await response.json(); + const highlighted = body.override?.id || payload.normalized || payload.token; + applyEntityPayload(body, { highlightId: highlighted }); + activateEntityTab("manual"); + if (manualOverrideResultsList) { + manualOverrideResultsList.innerHTML = ""; + } + } catch (error) { + console.error("Failed to create manual override", error); + } + } + + tabButtons.forEach((button) => { + button.addEventListener("click", () => { + activateEntityTab(button.dataset.panel || "people"); + }); + }); + + if (entitiesRefreshButton) { + entitiesRefreshButton.addEventListener("click", () => { + performEntitiesRefresh(true); + }); + } + + if (entityListNode) { + entityListNode.addEventListener("click", (event) => { + const trigger = event.target.closest('[data-role="entity-add-override"]'); + if (!trigger) return; + event.preventDefault(); + createOverrideFromData({ + token: trigger.dataset.entityToken || "", + normalized: trigger.dataset.entityNormalized || "", + context: trigger.dataset.entityContext || "", + source: "entity", + }); + }); + } + + if (manualOverrideSearchButton) { + manualOverrideSearchButton.addEventListener("click", () => { + const query = manualOverrideQueryInput?.value?.trim() || ""; + performManualOverrideSearch(query); + }); + } + + if (manualOverrideQueryInput) { + manualOverrideQueryInput.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + performManualOverrideSearch(manualOverrideQueryInput.value.trim()); + } + }); + } + + if (manualOverrideAddCustomButton) { + manualOverrideAddCustomButton.addEventListener("click", () => { + const token = window.prompt("Enter the token to override:"); + if (!token) return; + createOverrideFromData({ token: token.trim(), source: "manual" }); + }); + } + + if (manualOverrideResultsList) { + manualOverrideResultsList.addEventListener("click", (event) => { + const resultButton = event.target.closest('[data-role="manual-override-result"]'); + if (!resultButton) return; + event.preventDefault(); + createOverrideFromData({ + token: resultButton.dataset.token || "", + normalized: resultButton.dataset.normalized || "", + context: resultButton.dataset.context || "", + pronunciation: resultButton.dataset.pronunciation || "", + voice: resultButton.dataset.voice || "", + source: resultButton.dataset.source || "search", + }); + }); + } + + if (manualOverrideList) { + manualOverrideList.addEventListener("input", (event) => { + const input = event.target.closest('[data-role="manual-override-pronunciation"]'); + if (!input) return; + const overrideId = input.dataset.overrideId || input.closest('[data-override-id]')?.dataset.overrideId; + const container = input.closest(".manual-override"); + const previewButton = container?.querySelector('[data-role="speaker-preview"]'); + if (previewButton) { + previewButton.dataset.previewText = input.value.trim() || input.placeholder || ""; + } + if (overrideId) { + scheduleManualOverrideSave(overrideId); + } + }); + + manualOverrideList.addEventListener("change", (event) => { + const select = event.target.closest('[data-role="manual-override-voice"]'); + if (!select) return; + const overrideId = select.dataset.overrideId || select.closest('[data-override-id]')?.dataset.overrideId; + const container = select.closest(".manual-override"); + const previewButton = container?.querySelector('[data-role="speaker-preview"]'); + if (previewButton) { + previewButton.dataset.voice = select.value || baseVoice || select.dataset.defaultVoice || ""; + } + if (overrideId) { + scheduleManualOverrideSave(overrideId); + } + }); + + manualOverrideList.addEventListener("click", (event) => { + const deleteButton = event.target.closest('[data-role="manual-override-delete"]'); + if (!deleteButton) return; + event.preventDefault(); + const overrideId = deleteButton.dataset.overrideId; + if (!overrideId) return; + deleteManualOverride(overrideId); + }); + } + + const initialTab = tabButtons.find((button) => button.classList.contains("is-active")); + activateEntityTab(initialTab?.dataset.panel || "people"); + renderEntitySummary(); + renderManualOverrides(); + } + form.addEventListener("click", (event) => { const genderMenu = event.target.closest('[data-role="gender-menu"]'); const genderPill = event.target.closest('[data-role="gender-pill"]'); diff --git a/abogen/web/templates/index.html b/abogen/web/templates/index.html index d8595d8..597abd9 100644 --- a/abogen/web/templates/index.html +++ b/abogen/web/templates/index.html @@ -5,7 +5,7 @@ {% block content %}

Create a New Audiobook

-

Kick off a fresh conversion with your manuscript or pasted text. You can fine-tune chapters and speakers in the next steps.

+

Kick off a fresh conversion with your manuscript or pasted text. You can fine-tune chapters and entities in the next steps.

diff --git a/abogen/web/templates/partials/upload_modal.html b/abogen/web/templates/partials/upload_modal.html index 6241037..fb3d150 100644 --- a/abogen/web/templates/partials/upload_modal.html +++ b/abogen/web/templates/partials/upload_modal.html @@ -42,9 +42,9 @@ {% set narrator_voice = options.voices[0] %} {% endif %} {% set speed_display = '%.2f'|format(narrator_speed) %} -{% set step1_state = 'is-active' if modal_step in ['settings', 'upload'] else ('is-complete' if modal_step in ['chapters', 'speakers'] else '') %} -{% set step2_state = 'is-active' if modal_step == 'chapters' else ('is-complete' if modal_step == 'speakers' else '') %} -{% set step3_state = 'is-active' if modal_step == 'speakers' else '' %} +{% set step1_state = 'is-active' if modal_step in ['settings', 'upload'] else ('is-complete' if modal_step in ['chapters', 'entities'] else '') %} +{% set step2_state = 'is-active' if modal_step == 'chapters' else ('is-complete' if modal_step == 'entities' else '') %} +{% set step3_state = 'is-active' if modal_step == 'entities' else '' %}
-

Speakers & casting

+

Entities & casting

@@ -212,7 +212,7 @@
-

Speakers appearing less often fall back to the narrator voice.

+

Entities appearing less often fall back to the narrator voice.

diff --git a/abogen/web/templates/prepare_chapters.html b/abogen/web/templates/prepare_chapters.html index f3ef178..fafffd2 100644 --- a/abogen/web/templates/prepare_chapters.html +++ b/abogen/web/templates/prepare_chapters.html @@ -27,15 +27,15 @@ {% if is_multi_speaker %} + href="{{ url_for('web.prepare_job', pending_id=pending.id, step='entities') }}"> 3 - Speakers + Entities {% endif %} -

Choose which chapters to convert. We'll analyse speakers automatically when you continue.

+

Choose which chapters to convert. We'll analyse entities automatically when you continue.

{{ pending.original_filename }}

@@ -168,7 +168,7 @@ data-step-toggle="analysis" formaction="{{ url_for('web.analyze_pending_job', pending_id=pending.id) }}" formmethod="post"> - Continue to speakers + Continue to entities {% endif %} + + 2 + Chapters + + {% if is_multi_speaker %} + + 3 + Entities + + {% endif %} + + + +

Assign voices, tune pronunciations, and curate manual overrides before queueing the conversion.

+
+
+

{{ pending.original_filename }}

+
+ + +
+ + + + + + +
+ + +
+
+
+ + +
+
+
+{% with pending=pending, readonly=True, active_step='entities' %} + {% include "partials/upload_modal.html" %} +{% endwith %} +{% endblock %} + +{% block scripts %} + {{ super() }} + + + + + + + + + + +{% endblock %} diff --git a/abogen/web/templates/prepare_job.html b/abogen/web/templates/prepare_job.html index 345c96c..a349ad4 100644 --- a/abogen/web/templates/prepare_job.html +++ b/abogen/web/templates/prepare_job.html @@ -1,3 +1,3 @@ {# This template is intentionally left empty. - Step-specific templates now live in `prepare_chapters.html` and `prepare_speakers.html`. + Step-specific templates now live in `prepare_chapters.html` and `prepare_entities.html`. The file is kept as a placeholder to avoid breaking documentation references. #} diff --git a/abogen/web/templates/prepare_speakers.html b/abogen/web/templates/prepare_speakers.html index 34f0bba..822c72e 100644 --- a/abogen/web/templates/prepare_speakers.html +++ b/abogen/web/templates/prepare_speakers.html @@ -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 %} -
-