mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 21:50:28 +02:00
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:
@@ -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))
|
||||
@@ -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
@@ -50,6 +50,18 @@ from abogen.utils import (
|
||||
load_numpy_kpipeline,
|
||||
save_config,
|
||||
)
|
||||
from abogen.entity_analysis import (
|
||||
extract_entities,
|
||||
merge_override,
|
||||
normalize_token as normalize_entity_token,
|
||||
search_tokens as search_entity_tokens,
|
||||
)
|
||||
from abogen.pronunciation_store import (
|
||||
delete_override as delete_pronunciation_override,
|
||||
load_overrides as load_pronunciation_overrides,
|
||||
save_override as save_pronunciation_override,
|
||||
search_overrides as search_pronunciation_overrides,
|
||||
)
|
||||
from abogen.voice_profiles import (
|
||||
delete_profile,
|
||||
duplicate_profile,
|
||||
@@ -958,6 +970,318 @@ def _prepare_speaker_metadata(
|
||||
return chunk_list, roster, analysis_payload, applied_languages, updated_config
|
||||
|
||||
|
||||
def _collect_pronunciation_overrides(pending: PendingJob) -> List[Dict[str, Any]]:
|
||||
language = pending.language or "en"
|
||||
collected: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
summary = pending.entity_summary or {}
|
||||
for group in ("people", "entities"):
|
||||
entries = summary.get(group)
|
||||
if not isinstance(entries, list):
|
||||
continue
|
||||
for entry in entries:
|
||||
if not isinstance(entry, Mapping):
|
||||
continue
|
||||
override_payload = entry.get("override")
|
||||
if not isinstance(override_payload, Mapping):
|
||||
continue
|
||||
token_value = str(entry.get("label") or override_payload.get("token") or "").strip()
|
||||
pronunciation_value = str(override_payload.get("pronunciation") or "").strip()
|
||||
if not token_value or not pronunciation_value:
|
||||
continue
|
||||
normalized = normalize_entity_token(entry.get("normalized") or token_value)
|
||||
if not normalized:
|
||||
continue
|
||||
collected[normalized] = {
|
||||
"token": token_value,
|
||||
"normalized": normalized,
|
||||
"pronunciation": pronunciation_value,
|
||||
"voice": str(override_payload.get("voice") or "").strip() or None,
|
||||
"notes": str(override_payload.get("notes") or "").strip() or None,
|
||||
"context": str(override_payload.get("context") or "").strip() or None,
|
||||
"source": f"{group}-override",
|
||||
"language": language,
|
||||
}
|
||||
|
||||
if isinstance(pending.speakers, Mapping):
|
||||
for speaker_payload in pending.speakers.values():
|
||||
if not isinstance(speaker_payload, Mapping):
|
||||
continue
|
||||
token_value = str(speaker_payload.get("label") or "").strip()
|
||||
pronunciation_value = str(speaker_payload.get("pronunciation") or "").strip()
|
||||
if not token_value or not pronunciation_value:
|
||||
continue
|
||||
normalized = normalize_entity_token(token_value)
|
||||
if not normalized:
|
||||
continue
|
||||
collected[normalized] = {
|
||||
"token": token_value,
|
||||
"normalized": normalized,
|
||||
"pronunciation": pronunciation_value,
|
||||
"voice": str(
|
||||
speaker_payload.get("resolved_voice")
|
||||
or speaker_payload.get("voice")
|
||||
or pending.voice
|
||||
).strip()
|
||||
or None,
|
||||
"notes": None,
|
||||
"context": None,
|
||||
"source": "speaker",
|
||||
"language": language,
|
||||
}
|
||||
|
||||
for manual_entry in pending.manual_overrides or []:
|
||||
if not isinstance(manual_entry, Mapping):
|
||||
continue
|
||||
token_value = str(manual_entry.get("token") or "").strip()
|
||||
pronunciation_value = str(manual_entry.get("pronunciation") or "").strip()
|
||||
if not token_value or not pronunciation_value:
|
||||
continue
|
||||
normalized = manual_entry.get("normalized") or normalize_entity_token(token_value)
|
||||
if not normalized:
|
||||
continue
|
||||
collected[normalized] = {
|
||||
"token": token_value,
|
||||
"normalized": normalized,
|
||||
"pronunciation": pronunciation_value,
|
||||
"voice": str(manual_entry.get("voice") or "").strip() or None,
|
||||
"notes": str(manual_entry.get("notes") or "").strip() or None,
|
||||
"context": str(manual_entry.get("context") or "").strip() or None,
|
||||
"source": str(manual_entry.get("source") or "manual"),
|
||||
"language": language,
|
||||
}
|
||||
|
||||
return list(collected.values())
|
||||
|
||||
|
||||
def _sync_pronunciation_overrides(pending: PendingJob) -> None:
|
||||
pending.pronunciation_overrides = _collect_pronunciation_overrides(pending)
|
||||
|
||||
if not pending.pronunciation_overrides:
|
||||
return
|
||||
|
||||
summary = pending.entity_summary or {}
|
||||
manual_map: Dict[str, Mapping[str, Any]] = {}
|
||||
for override in pending.manual_overrides or []:
|
||||
if not isinstance(override, Mapping):
|
||||
continue
|
||||
normalized = override.get("normalized") or normalize_entity_token(override.get("token") or "")
|
||||
pronunciation_value = str(override.get("pronunciation") or "").strip()
|
||||
if not normalized or not pronunciation_value:
|
||||
continue
|
||||
manual_map[normalized] = override
|
||||
for group in ("people", "entities"):
|
||||
entries = summary.get(group)
|
||||
if not isinstance(entries, list):
|
||||
continue
|
||||
for entry in entries:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
normalized = normalize_entity_token(entry.get("normalized") or entry.get("label") or "")
|
||||
manual_override = manual_map.get(normalized)
|
||||
if manual_override:
|
||||
entry["override"] = {
|
||||
"token": manual_override.get("token"),
|
||||
"pronunciation": manual_override.get("pronunciation"),
|
||||
"voice": manual_override.get("voice"),
|
||||
"notes": manual_override.get("notes"),
|
||||
"context": manual_override.get("context"),
|
||||
"source": manual_override.get("source"),
|
||||
}
|
||||
|
||||
|
||||
def _refresh_entity_summary(pending: PendingJob, chapters: Iterable[Mapping[str, Any]]) -> None:
|
||||
language = pending.language or "en"
|
||||
result = extract_entities(chapters, language=language)
|
||||
summary = dict(result.summary)
|
||||
tokens: List[str] = []
|
||||
for group in ("people", "entities"):
|
||||
entries = summary.get(group)
|
||||
if not isinstance(entries, list):
|
||||
continue
|
||||
for entry in entries:
|
||||
if not isinstance(entry, Mapping):
|
||||
continue
|
||||
token_value = str(entry.get("normalized") or entry.get("label") or "").strip()
|
||||
if token_value:
|
||||
tokens.append(token_value)
|
||||
|
||||
overrides_from_store = load_pronunciation_overrides(language=language, tokens=tokens)
|
||||
merged_summary = merge_override(summary, overrides_from_store)
|
||||
if result.errors:
|
||||
merged_summary["errors"] = list(result.errors)
|
||||
merged_summary["cache_key"] = result.cache_key
|
||||
pending.entity_summary = merged_summary
|
||||
pending.entity_cache_key = result.cache_key
|
||||
_sync_pronunciation_overrides(pending)
|
||||
|
||||
|
||||
def _find_manual_override(pending: PendingJob, identifier: str) -> Optional[Dict[str, Any]]:
|
||||
for entry in pending.manual_overrides or []:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
if entry.get("id") == identifier or entry.get("normalized") == identifier:
|
||||
return entry
|
||||
return None
|
||||
|
||||
|
||||
def _upsert_manual_override(pending: PendingJob, payload: Mapping[str, Any]) -> Dict[str, Any]:
|
||||
token_value = str(payload.get("token") or "").strip()
|
||||
if not token_value:
|
||||
raise ValueError("Token is required")
|
||||
pronunciation_value = str(payload.get("pronunciation") or "").strip()
|
||||
voice_value = str(payload.get("voice") or "").strip()
|
||||
notes_value = str(payload.get("notes") or "").strip()
|
||||
context_value = str(payload.get("context") or "").strip()
|
||||
normalized = payload.get("normalized") or normalize_entity_token(token_value)
|
||||
if not normalized:
|
||||
raise ValueError("Token is required")
|
||||
|
||||
existing = _find_manual_override(pending, payload.get("id", "")) or _find_manual_override(pending, normalized)
|
||||
timestamp = time.time()
|
||||
language = pending.language or "en"
|
||||
|
||||
if existing:
|
||||
existing.update(
|
||||
{
|
||||
"token": token_value,
|
||||
"normalized": normalized,
|
||||
"pronunciation": pronunciation_value,
|
||||
"voice": voice_value,
|
||||
"notes": notes_value,
|
||||
"context": context_value,
|
||||
"updated_at": timestamp,
|
||||
}
|
||||
)
|
||||
manual_entry = existing
|
||||
else:
|
||||
manual_entry = {
|
||||
"id": payload.get("id") or uuid.uuid4().hex,
|
||||
"token": token_value,
|
||||
"normalized": normalized,
|
||||
"pronunciation": pronunciation_value,
|
||||
"voice": voice_value,
|
||||
"notes": notes_value,
|
||||
"context": context_value,
|
||||
"language": language,
|
||||
"source": payload.get("source") or "manual",
|
||||
"created_at": timestamp,
|
||||
"updated_at": timestamp,
|
||||
}
|
||||
if isinstance(pending.manual_overrides, list):
|
||||
pending.manual_overrides.append(manual_entry)
|
||||
else:
|
||||
pending.manual_overrides = [manual_entry]
|
||||
|
||||
save_pronunciation_override(
|
||||
language=language,
|
||||
token=token_value,
|
||||
pronunciation=pronunciation_value or None,
|
||||
voice=voice_value or None,
|
||||
notes=notes_value or None,
|
||||
context=context_value or None,
|
||||
)
|
||||
|
||||
_sync_pronunciation_overrides(pending)
|
||||
return dict(manual_entry)
|
||||
|
||||
|
||||
def _delete_manual_override(pending: PendingJob, override_id: str) -> bool:
|
||||
if not override_id:
|
||||
return False
|
||||
entries = pending.manual_overrides or []
|
||||
for index, entry in enumerate(entries):
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
if entry.get("id") == override_id:
|
||||
token_value = entry.get("token") or ""
|
||||
language = pending.language or "en"
|
||||
delete_pronunciation_override(language=language, token=token_value)
|
||||
entries.pop(index)
|
||||
pending.manual_overrides = entries
|
||||
_sync_pronunciation_overrides(pending)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _search_manual_override_candidates(pending: PendingJob, query: str, *, limit: int = 15) -> List[Dict[str, Any]]:
|
||||
normalized_query = (query or "").strip()
|
||||
summary_index = (pending.entity_summary or {}).get("index", {})
|
||||
matches = search_entity_tokens(summary_index, normalized_query, limit=limit)
|
||||
registry: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
for entry in matches:
|
||||
normalized = normalize_entity_token(entry.get("normalized") or entry.get("token") or "")
|
||||
if not normalized:
|
||||
continue
|
||||
registry.setdefault(
|
||||
normalized,
|
||||
{
|
||||
"token": entry.get("token"),
|
||||
"normalized": normalized,
|
||||
"category": entry.get("category") or "entity",
|
||||
"count": entry.get("count", 0),
|
||||
"samples": entry.get("samples", []),
|
||||
"source": "entity",
|
||||
},
|
||||
)
|
||||
|
||||
language = pending.language or "en"
|
||||
store_matches = search_pronunciation_overrides(language=language, query=normalized_query, limit=limit)
|
||||
for entry in store_matches:
|
||||
normalized = entry.get("normalized")
|
||||
if not normalized:
|
||||
continue
|
||||
registry.setdefault(
|
||||
normalized,
|
||||
{
|
||||
"token": entry.get("token"),
|
||||
"normalized": normalized,
|
||||
"category": "history",
|
||||
"count": entry.get("usage_count", 0),
|
||||
"samples": [entry.get("context")] if entry.get("context") else [],
|
||||
"source": "history",
|
||||
"pronunciation": entry.get("pronunciation"),
|
||||
"voice": entry.get("voice"),
|
||||
},
|
||||
)
|
||||
|
||||
for entry in pending.manual_overrides or []:
|
||||
if not isinstance(entry, Mapping):
|
||||
continue
|
||||
normalized = entry.get("normalized")
|
||||
if not normalized:
|
||||
continue
|
||||
registry.setdefault(
|
||||
normalized,
|
||||
{
|
||||
"token": entry.get("token"),
|
||||
"normalized": normalized,
|
||||
"category": "manual",
|
||||
"count": 0,
|
||||
"samples": [entry.get("context")] if entry.get("context") else [],
|
||||
"source": "manual",
|
||||
"pronunciation": entry.get("pronunciation"),
|
||||
"voice": entry.get("voice"),
|
||||
},
|
||||
)
|
||||
|
||||
ordered = sorted(registry.values(), key=lambda item: (-int(item.get("count") or 0), item.get("token") or ""))
|
||||
if limit:
|
||||
return ordered[:limit]
|
||||
return ordered
|
||||
|
||||
|
||||
def _pending_entities_payload(pending: PendingJob) -> Dict[str, Any]:
|
||||
return {
|
||||
"summary": pending.entity_summary or {},
|
||||
"manual_overrides": pending.manual_overrides or [],
|
||||
"pronunciation_overrides": pending.pronunciation_overrides or [],
|
||||
"cache_key": pending.entity_cache_key,
|
||||
"language": pending.language or "en",
|
||||
}
|
||||
|
||||
|
||||
def _apply_prepare_form(
|
||||
pending: PendingJob, form: Mapping[str, Any]
|
||||
) -> tuple[
|
||||
@@ -1141,6 +1465,8 @@ def _apply_prepare_form(
|
||||
|
||||
enabled_overrides = [entry for entry in overrides if entry.get("enabled")]
|
||||
|
||||
_sync_pronunciation_overrides(pending)
|
||||
|
||||
return (
|
||||
chunk_level_literal,
|
||||
overrides,
|
||||
@@ -1261,6 +1587,13 @@ def _service() -> ConversionService:
|
||||
return current_app.extensions["conversion_service"]
|
||||
|
||||
|
||||
def _require_pending_job(pending_id: str) -> PendingJob:
|
||||
pending = _service().get_pending_job(pending_id)
|
||||
if not pending:
|
||||
abort(404)
|
||||
return cast(PendingJob, pending)
|
||||
|
||||
|
||||
def _build_voice_catalog() -> List[Dict[str, str]]:
|
||||
catalog: List[Dict[str, str]] = []
|
||||
gender_map = {"f": "Female", "m": "Male"}
|
||||
@@ -2101,6 +2434,74 @@ def api_speaker_preview() -> ResponseReturnValue:
|
||||
return response
|
||||
|
||||
|
||||
@api_bp.get("/pending/<pending_id>/entities")
|
||||
def api_pending_entities(pending_id: str) -> ResponseReturnValue:
|
||||
pending = _require_pending_job(pending_id)
|
||||
refresh_flag = (request.args.get("refresh") or "").strip().lower()
|
||||
expected_cache = (request.args.get("cache_key") or "").strip()
|
||||
refresh_requested = refresh_flag in {"1", "true", "yes", "force"}
|
||||
if expected_cache and expected_cache != (pending.entity_cache_key or ""):
|
||||
refresh_requested = True
|
||||
if refresh_requested or not pending.entity_summary:
|
||||
_refresh_entity_summary(pending, pending.chapters)
|
||||
_service().store_pending_job(pending)
|
||||
return jsonify(_pending_entities_payload(pending))
|
||||
|
||||
|
||||
@api_bp.post("/pending/<pending_id>/entities/refresh")
|
||||
def api_refresh_pending_entities(pending_id: str) -> ResponseReturnValue:
|
||||
pending = _require_pending_job(pending_id)
|
||||
_refresh_entity_summary(pending, pending.chapters)
|
||||
_service().store_pending_job(pending)
|
||||
return jsonify(_pending_entities_payload(pending))
|
||||
|
||||
|
||||
@api_bp.get("/pending/<pending_id>/manual-overrides")
|
||||
def api_list_manual_overrides(pending_id: str) -> ResponseReturnValue:
|
||||
pending = _require_pending_job(pending_id)
|
||||
return jsonify(
|
||||
{
|
||||
"overrides": pending.manual_overrides or [],
|
||||
"pronunciation_overrides": pending.pronunciation_overrides or [],
|
||||
"language": pending.language or "en",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@api_bp.post("/pending/<pending_id>/manual-overrides")
|
||||
def api_upsert_manual_override(pending_id: str) -> ResponseReturnValue:
|
||||
pending = _require_pending_job(pending_id)
|
||||
payload = request.get_json(silent=True) or {}
|
||||
if not isinstance(payload, Mapping):
|
||||
abort(400, "Invalid override payload")
|
||||
try:
|
||||
override = _upsert_manual_override(pending, payload)
|
||||
except ValueError as exc:
|
||||
abort(400, str(exc))
|
||||
_service().store_pending_job(pending)
|
||||
return jsonify({"override": override, **_pending_entities_payload(pending)})
|
||||
|
||||
|
||||
@api_bp.delete("/pending/<pending_id>/manual-overrides/<override_id>")
|
||||
def api_delete_manual_override(pending_id: str, override_id: str) -> ResponseReturnValue:
|
||||
pending = _require_pending_job(pending_id)
|
||||
deleted = _delete_manual_override(pending, override_id)
|
||||
if not deleted:
|
||||
abort(404)
|
||||
_service().store_pending_job(pending)
|
||||
return jsonify({"deleted": True, **_pending_entities_payload(pending)})
|
||||
|
||||
|
||||
@api_bp.get("/pending/<pending_id>/manual-overrides/search")
|
||||
def api_search_manual_override_candidates(pending_id: str) -> ResponseReturnValue:
|
||||
pending = _require_pending_job(pending_id)
|
||||
query = (request.args.get("q") or request.args.get("query") or "").strip()
|
||||
limit_param = request.args.get("limit")
|
||||
limit_value = _coerce_int(limit_param, 15, minimum=1, maximum=50) if limit_param is not None else 15
|
||||
results = _search_manual_override_candidates(pending, query, limit=limit_value)
|
||||
return jsonify({"query": query, "limit": limit_value, "results": results})
|
||||
|
||||
|
||||
@web_bp.post("/jobs")
|
||||
def enqueue_job() -> ResponseReturnValue:
|
||||
service = _service()
|
||||
@@ -2310,6 +2711,8 @@ def enqueue_job() -> ResponseReturnValue:
|
||||
analysis_requested=initial_analysis,
|
||||
)
|
||||
|
||||
_refresh_entity_summary(pending, pending.chapters)
|
||||
|
||||
service.store_pending_job(pending)
|
||||
pending.applied_speaker_config = selected_speaker_config or None
|
||||
if config_languages:
|
||||
@@ -2323,20 +2726,14 @@ def enqueue_job() -> ResponseReturnValue:
|
||||
|
||||
@web_bp.get("/jobs/prepare/<pending_id>")
|
||||
def prepare_job(pending_id: str) -> str:
|
||||
pending = _service().get_pending_job(pending_id)
|
||||
if not pending:
|
||||
abort(404)
|
||||
pending = cast(PendingJob, pending)
|
||||
pending = _require_pending_job(pending_id)
|
||||
return _render_prepare_page(pending, active_step="chapters")
|
||||
|
||||
|
||||
@web_bp.post("/jobs/prepare/<pending_id>/analyze")
|
||||
def analyze_pending_job(pending_id: str) -> ResponseReturnValue:
|
||||
service = _service()
|
||||
pending = service.get_pending_job(pending_id)
|
||||
if not pending:
|
||||
abort(404)
|
||||
pending = cast(PendingJob, pending)
|
||||
pending = _require_pending_job(pending_id)
|
||||
|
||||
(
|
||||
chunk_level_literal,
|
||||
@@ -2412,21 +2809,20 @@ def analyze_pending_job(pending_id: str) -> ResponseReturnValue:
|
||||
if selected_total:
|
||||
pending.total_characters = selected_total
|
||||
|
||||
_sync_pronunciation_overrides(pending)
|
||||
|
||||
service.store_pending_job(pending)
|
||||
|
||||
notice_message = "Speaker analysis updated."
|
||||
notice_message = "Entity insights updated."
|
||||
if persist_config_requested and config_name:
|
||||
notice_message = "Speaker analysis updated and configuration saved."
|
||||
return _render_prepare_page(pending, notice=notice_message, active_step="speakers")
|
||||
notice_message = "Entity insights updated and configuration saved."
|
||||
return _render_prepare_page(pending, notice=notice_message, active_step="entities")
|
||||
|
||||
|
||||
@web_bp.post("/jobs/prepare/<pending_id>")
|
||||
def finalize_job(pending_id: str) -> ResponseReturnValue:
|
||||
service = _service()
|
||||
pending = service.get_pending_job(pending_id)
|
||||
if not pending:
|
||||
abort(404)
|
||||
pending = cast(PendingJob, pending)
|
||||
pending = _require_pending_job(pending_id)
|
||||
|
||||
(
|
||||
chunk_level_literal,
|
||||
@@ -2443,7 +2839,7 @@ def finalize_job(pending_id: str) -> ResponseReturnValue:
|
||||
return _render_prepare_page(
|
||||
pending,
|
||||
error=" ".join(errors),
|
||||
active_step=request.form.get("active_step") or "speakers",
|
||||
active_step=request.form.get("active_step") or "entities",
|
||||
)
|
||||
|
||||
if pending.speaker_mode != "multi":
|
||||
@@ -2458,12 +2854,14 @@ def finalize_job(pending_id: str) -> ResponseReturnValue:
|
||||
)
|
||||
|
||||
active_step = (request.form.get("active_step") or "chapters").strip().lower()
|
||||
if active_step == "speakers":
|
||||
active_step = "entities"
|
||||
|
||||
raw_chunks = build_chunks_for_chapters(enabled_overrides, level=chunk_level_literal)
|
||||
analysis_chunks = build_chunks_for_chapters(enabled_overrides, level="sentence")
|
||||
is_multi = pending.speaker_mode == "multi"
|
||||
analysis_requested = bool(getattr(pending, "analysis_requested", False))
|
||||
should_force_speakers = is_multi and active_step != "speakers"
|
||||
should_force_entities = is_multi and active_step != "entities"
|
||||
|
||||
if analysis_requested:
|
||||
existing_roster: Optional[Mapping[str, Any]] = pending.speakers
|
||||
@@ -2477,7 +2875,7 @@ def finalize_job(pending_id: str) -> ResponseReturnValue:
|
||||
|
||||
config_name = pending.applied_speaker_config or selected_config
|
||||
speaker_config_payload = get_config(config_name) if config_name else None
|
||||
run_analysis = is_multi and (should_force_speakers or analysis_requested)
|
||||
run_analysis = is_multi and (should_force_entities or analysis_requested)
|
||||
processed_chunks, roster, analysis_payload, config_languages, updated_config = _prepare_speaker_metadata(
|
||||
chapters=enabled_overrides,
|
||||
chunks=raw_chunks,
|
||||
@@ -2511,15 +2909,17 @@ def finalize_job(pending_id: str) -> ResponseReturnValue:
|
||||
if selected_total:
|
||||
pending.total_characters = selected_total
|
||||
|
||||
if should_force_speakers:
|
||||
notice_message = "Review speaker assignments before queuing."
|
||||
_sync_pronunciation_overrides(pending)
|
||||
|
||||
if should_force_entities:
|
||||
notice_message = "Review entity settings before queuing."
|
||||
if persist_config_requested and config_key:
|
||||
notice_message = "Configuration saved. Review speaker assignments before queuing."
|
||||
notice_message = "Configuration saved. Review entity settings before queuing."
|
||||
service.store_pending_job(pending)
|
||||
return _render_prepare_page(
|
||||
pending,
|
||||
notice=notice_message,
|
||||
active_step="speakers",
|
||||
active_step="entities",
|
||||
)
|
||||
|
||||
total_characters = selected_total or pending.total_characters
|
||||
@@ -2559,6 +2959,9 @@ def finalize_job(pending_id: str) -> ResponseReturnValue:
|
||||
speaker_analysis=pending.speaker_analysis,
|
||||
speaker_analysis_threshold=pending.speaker_analysis_threshold,
|
||||
analysis_requested=getattr(pending, "analysis_requested", False),
|
||||
entity_summary=pending.entity_summary,
|
||||
manual_overrides=pending.manual_overrides,
|
||||
pronunciation_overrides=pending.pronunciation_overrides,
|
||||
)
|
||||
|
||||
if config_languages:
|
||||
@@ -2620,14 +3023,16 @@ def _render_prepare_page(
|
||||
) or "chapters"
|
||||
|
||||
normalized_step = (active_step or "chapters").strip().lower()
|
||||
if normalized_step not in {"chapters", "speakers"}:
|
||||
if normalized_step == "speakers":
|
||||
normalized_step = "entities"
|
||||
if normalized_step not in {"chapters", "entities"}:
|
||||
normalized_step = "chapters"
|
||||
|
||||
is_multi = pending.speaker_mode == "multi"
|
||||
if normalized_step == "speakers" and not is_multi:
|
||||
if normalized_step == "entities" and not is_multi:
|
||||
normalized_step = "chapters"
|
||||
|
||||
template_name = "prepare_speakers.html" if normalized_step == "speakers" else "prepare_chapters.html"
|
||||
template_name = "prepare_entities.html" if normalized_step == "entities" else "prepare_chapters.html"
|
||||
|
||||
return render_template(
|
||||
template_name,
|
||||
|
||||
+28
-3
@@ -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
|
||||
|
||||
|
||||
@@ -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"]');
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{% block content %}
|
||||
<section class="card card--workflow">
|
||||
<h1 class="card__title">Create a New Audiobook</h1>
|
||||
<p class="card__subtitle">Kick off a fresh conversion with your manuscript or pasted text. You can fine-tune chapters and speakers in the next steps.</p>
|
||||
<p class="card__subtitle">Kick off a fresh conversion with your manuscript or pasted text. You can fine-tune chapters and entities in the next steps.</p>
|
||||
<div class="upload-card__dropzone" data-role="upload-dropzone" tabindex="0" role="button" aria-label="Open upload settings or drop a manuscript file">
|
||||
<div class="upload-card__dropzone-content">
|
||||
<span class="upload-card__icon" aria-hidden="true">↑</span>
|
||||
|
||||
@@ -42,9 +42,9 @@
|
||||
{% set narrator_voice = options.voices[0] %}
|
||||
{% endif %}
|
||||
{% set speed_display = '%.2f'|format(narrator_speed) %}
|
||||
{% set step1_state = 'is-active' if modal_step in ['settings', 'upload'] else ('is-complete' if modal_step in ['chapters', 'speakers'] else '') %}
|
||||
{% set step2_state = 'is-active' if modal_step == 'chapters' else ('is-complete' if modal_step == 'speakers' else '') %}
|
||||
{% set step3_state = 'is-active' if modal_step == 'speakers' else '' %}
|
||||
{% set step1_state = 'is-active' if modal_step in ['settings', 'upload'] else ('is-complete' if modal_step in ['chapters', 'entities'] else '') %}
|
||||
{% set step2_state = 'is-active' if modal_step == 'chapters' else ('is-complete' if modal_step == 'entities' else '') %}
|
||||
{% set step3_state = 'is-active' if modal_step == 'entities' else '' %}
|
||||
<div class="modal" data-role="upload-modal" hidden>
|
||||
<div class="modal__overlay" data-role="upload-modal-close" tabindex="-1"></div>
|
||||
<div class="modal__content card card--modal wizard-card" role="dialog" aria-modal="true" aria-labelledby="upload-modal-title">
|
||||
@@ -62,7 +62,7 @@
|
||||
{% if is_multi %}
|
||||
<span class="step-indicator__item {{ step3_state }}">
|
||||
<span class="step-indicator__index">3</span>
|
||||
<span class="step-indicator__label">Speakers</span>
|
||||
<span class="step-indicator__label">Entities</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
</nav>
|
||||
@@ -177,7 +177,7 @@
|
||||
</section>
|
||||
|
||||
<section class="form-section">
|
||||
<h3 class="form-section__title">Speakers & casting</h3>
|
||||
<h3 class="form-section__title">Entities & casting</h3>
|
||||
<div class="form-section__layout">
|
||||
<div class="field-grid field-grid--two">
|
||||
<div class="field field--stack">
|
||||
@@ -212,7 +212,7 @@
|
||||
<div class="field">
|
||||
<label for="speaker_analysis_threshold">Speaker analysis minimum mentions</label>
|
||||
<input type="number" min="1" max="25" id="speaker_analysis_threshold" name="speaker_analysis_threshold" value="{{ analysis_threshold_value }}" {{ 'disabled' if readonly else '' }}>
|
||||
<p class="hint">Speakers appearing less often fall back to the narrator voice.</p>
|
||||
<p class="hint">Entities appearing less often fall back to the narrator voice.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
|
||||
@@ -27,15 +27,15 @@
|
||||
</a>
|
||||
{% if is_multi_speaker %}
|
||||
<a class="step-indicator__item"
|
||||
href="{{ url_for('web.prepare_job', pending_id=pending.id, step='speakers') }}">
|
||||
href="{{ url_for('web.prepare_job', pending_id=pending.id, step='entities') }}">
|
||||
<span class="step-indicator__index">3</span>
|
||||
<span class="step-indicator__label">Speakers</span>
|
||||
<span class="step-indicator__label">Entities</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
<p class="modal__eyebrow">Step 2 of {{ total_steps }}</p>
|
||||
<h2 class="modal__title" id="prepare-chapters-title">Select chapters</h2>
|
||||
<p class="hint">Choose which chapters to convert. We'll analyse speakers automatically when you continue.</p>
|
||||
<p class="hint">Choose which chapters to convert. We'll analyse entities automatically when you continue.</p>
|
||||
</div>
|
||||
<div class="wizard-card__aside">
|
||||
<p class="wizard-card__filename" title="{{ pending.original_filename }}">{{ pending.original_filename }}</p>
|
||||
@@ -168,7 +168,7 @@
|
||||
data-step-toggle="analysis"
|
||||
formaction="{{ url_for('web.analyze_pending_job', pending_id=pending.id) }}"
|
||||
formmethod="post">
|
||||
Continue to speakers
|
||||
Continue to entities
|
||||
</button>
|
||||
{% endif %}
|
||||
<button type="submit" class="button" data-step-toggle="finalize"{% if is_multi_speaker %} hidden aria-hidden="true"{% endif %}>
|
||||
|
||||
@@ -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 & 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,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. #}
|
||||
|
||||
@@ -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 & settings</span>
|
||||
</button>
|
||||
<a class="step-indicator__item is-complete"
|
||||
href="{{ url_for('web.prepare_job', pending_id=pending.id, step='chapters') }}">
|
||||
<span class="step-indicator__index">2</span>
|
||||
<span class="step-indicator__label">Chapters</span>
|
||||
</a>
|
||||
{% if is_multi_speaker %}
|
||||
<a class="step-indicator__item is-active"
|
||||
aria-current="step"
|
||||
href="{{ url_for('web.prepare_job', pending_id=pending.id, step='speakers') }}">
|
||||
<span class="step-indicator__index">3</span>
|
||||
<span class="step-indicator__label">Speakers</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
<p class="modal__eyebrow">Step 3 of {{ total_steps }}</p>
|
||||
<h2 class="modal__title" id="prepare-speakers-title">Select speakers</h2>
|
||||
<p class="hint">Assign voices, audition samples, and finalise your roster before queueing the conversion.</p>
|
||||
</div>
|
||||
<div class="wizard-card__aside">
|
||||
<p class="wizard-card__filename" title="{{ pending.original_filename }}">{{ pending.original_filename }}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<form method="post"
|
||||
action="{{ url_for('web.finalize_job', pending_id=pending.id) }}"
|
||||
class="prepare-form"
|
||||
id="prepare-form"
|
||||
data-speaker-mode="{{ pending.speaker_mode }}"
|
||||
data-analyze-url="{{ url_for('web.analyze_pending_job', pending_id=pending.id) }}">
|
||||
<input type="hidden" name="active_step" value="speakers" data-role="active-step-input">
|
||||
|
||||
<div class="wizard-hidden-inputs" aria-hidden="true">
|
||||
<input type="hidden" name="chunk_level" value="{{ pending.chunk_level }}">
|
||||
<input type="hidden" name="speaker_mode" value="{{ pending.speaker_mode }}">
|
||||
<input type="hidden" name="speaker_analysis_threshold" value="{{ pending.speaker_analysis_threshold }}">
|
||||
<input type="hidden" name="chapter_intro_delay" value="{{ '%.2f'|format(pending.chapter_intro_delay) }}">
|
||||
{% if pending.generate_epub3 %}
|
||||
<input type="hidden" name="generate_epub3" value="true">
|
||||
{% endif %}
|
||||
{% for chapter in pending.chapters %}
|
||||
{% set selected_option = '__default' %}
|
||||
{% if chapter.voice_profile %}
|
||||
{% set selected_option = 'profile:' ~ chapter.voice_profile %}
|
||||
{% elif chapter.voice %}
|
||||
{% set selected_option = 'voice:' ~ chapter.voice %}
|
||||
{% elif chapter.voice_formula %}
|
||||
{% set selected_option = 'formula' %}
|
||||
{% endif %}
|
||||
{% if chapter.enabled %}
|
||||
<input type="hidden" name="chapter-{{ loop.index0 }}-enabled" value="on">
|
||||
{% endif %}
|
||||
<input type="hidden" name="chapter-{{ loop.index0 }}-title" value="{{ chapter.title }}">
|
||||
<input type="hidden" name="chapter-{{ loop.index0 }}-voice" value="{{ selected_option }}">
|
||||
{% if chapter.voice_formula %}
|
||||
<input type="hidden" name="chapter-{{ loop.index0 }}-formula" value="{{ chapter.voice_formula }}">
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="modal__body wizard-card__body">
|
||||
{% if error %}
|
||||
<div class="alert alert--error">{{ error }}</div>
|
||||
{% endif %}
|
||||
{% if notice %}
|
||||
<div class="alert alert--info">{{ notice }}</div>
|
||||
{% elif pending.speaker_mode != 'multi' %}
|
||||
<div class="alert alert--info">Multi-speaker mode is disabled. Switch back to chapters to enable additional voices.</div>
|
||||
{% endif %}
|
||||
|
||||
<section class="prepare-speaker-config">
|
||||
<div class="prepare-speaker-config__header">
|
||||
<h2>Speaker configuration</h2>
|
||||
<p class="hint">Reuse saved presets to keep character voices consistent between projects.</p>
|
||||
</div>
|
||||
<div class="prepare-speaker-config__grid">
|
||||
<label class="field prepare-speaker-config__field" for="applied_speaker_config">
|
||||
<span>Saved preset</span>
|
||||
<select id="applied_speaker_config" name="applied_speaker_config">
|
||||
<option value="">None</option>
|
||||
{% for config in speaker_configs %}
|
||||
<option value="{{ config.name }}" {% if pending.applied_speaker_config == config.name %}selected{% endif %}>
|
||||
{{ config.name }} · {{ config.speakers|length }} speaker{% if config.speakers|length != 1 %}s{% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="hint">Presets are managed on the <a href="{{ url_for('web.speaker_configs_page') }}">Speakers page</a>.</p>
|
||||
</label>
|
||||
<div class="prepare-speaker-config__actions">
|
||||
<button type="submit"
|
||||
class="button button--ghost"
|
||||
name="apply_speaker_config"
|
||||
data-role="submit-speaker-analysis"
|
||||
value="1"
|
||||
formaction="{{ url_for('web.analyze_pending_job', pending_id=pending.id) }}"
|
||||
formmethod="post"
|
||||
formnovalidate
|
||||
{% if not speaker_configs %}disabled{% endif %}>
|
||||
Apply preset
|
||||
</button>
|
||||
<label class="toggle-pill">
|
||||
<input type="checkbox" name="save_speaker_config" value="1">
|
||||
<span>Save roster updates back to preset</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% if pending.speakers %}
|
||||
<div class="prepare-speakers">
|
||||
<h2>Speaker settings</h2>
|
||||
<p class="hint">Set pronunciations, lock specific voices, and audition sample paragraphs to hear casting choices.</p>
|
||||
<ul class="speaker-list">
|
||||
{% for speaker_id, speaker in pending.speakers.items() %}
|
||||
{% set pronunciation_text = speaker.pronunciation or speaker.label %}
|
||||
{% set selected_voice = speaker.resolved_voice or speaker.voice %}
|
||||
{% set seen = namespace(values=[]) %}
|
||||
{% set sample_quotes = speaker.sample_quotes or [] %}
|
||||
{% set detected_gender = speaker.detected_gender or speaker.gender or 'unknown' %}
|
||||
{% set current_gender = speaker.gender or detected_gender %}
|
||||
{% set gender_label = 'Either' if current_gender == 'either' else (current_gender|title if current_gender != 'unknown' else 'Unknown') %}
|
||||
{% set detected_label = 'Either' if detected_gender == 'either' else (detected_gender|title if detected_gender != 'unknown' else 'Unknown') %}
|
||||
<li class="speaker-list__item"
|
||||
data-speaker-id="{{ speaker_id }}"
|
||||
data-default-pronunciation="{{ pronunciation_text }}">
|
||||
<div class="speaker-line speaker-list__header">
|
||||
<span class="speaker-list__name">{{ speaker.label }}</span>
|
||||
<button type="button"
|
||||
class="icon-button speaker-list__preview"
|
||||
data-role="speaker-preview"
|
||||
data-speaker-id="{{ speaker_id }}"
|
||||
data-preview-source="pronunciation"
|
||||
data-preview-text="{{ pronunciation_text|e }}"
|
||||
data-language="{{ pending.language }}"
|
||||
data-voice="{{ selected_voice or pending.voice }}"
|
||||
data-speed="{{ '%.2f'|format(pending.speed) }}"
|
||||
data-use-gpu="{{ 'true' if pending.use_gpu else 'false' }}"
|
||||
aria-label="Preview pronunciation for {{ speaker.label }}"
|
||||
title="Preview pronunciation">
|
||||
🔊
|
||||
</button>
|
||||
</div>
|
||||
<template data-role="speaker-samples">{{ sample_quotes | tojson }}</template>
|
||||
<div class="speaker-list__meta">
|
||||
<div class="speaker-gender" data-role="speaker-gender" data-speaker-id="{{ speaker_id }}">
|
||||
<button type="button"
|
||||
class="chip speaker-gender__pill"
|
||||
data-role="gender-pill"
|
||||
data-current="{{ current_gender }}">
|
||||
{{ gender_label }} voice
|
||||
</button>
|
||||
<div class="speaker-gender__menu" data-role="gender-menu" hidden>
|
||||
<p class="hint">Detected: {{ detected_label }}</p>
|
||||
<div class="speaker-gender__options">
|
||||
<button type="button" class="chip" data-role="gender-option" data-value="female">Female</button>
|
||||
<button type="button" class="chip" data-role="gender-option" data-value="male">Male</button>
|
||||
<button type="button" class="chip" data-role="gender-option" data-value="either">Either</button>
|
||||
<button type="button" class="chip" data-role="gender-option" data-value="{{ detected_gender }}" {% if detected_gender == current_gender %}data-state="active"{% endif %}>
|
||||
Use detected ({{ detected_label }})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" name="speaker-{{ speaker_id }}-gender" value="{{ current_gender }}" data-role="gender-input">
|
||||
<input type="hidden" name="speaker-{{ speaker_id }}-detected-gender" value="{{ detected_gender }}">
|
||||
</div>
|
||||
{% if speaker.get('analysis_count') %}
|
||||
<span class="badge badge--muted">{{ speaker.analysis_count }} lines · {{ speaker.analysis_confidence|default('low')|title }} confidence</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<label class="speaker-list__field" for="speaker-{{ speaker_id }}-pronunciation">
|
||||
<span>Pronunciation</span>
|
||||
<input type="text"
|
||||
id="speaker-{{ speaker_id }}-pronunciation"
|
||||
name="speaker-{{ speaker_id }}-pronunciation"
|
||||
value="{{ pronunciation_text }}"
|
||||
data-role="speaker-pronunciation"
|
||||
placeholder="{{ speaker.label }}">
|
||||
</label>
|
||||
<div class="speaker-list__controls">
|
||||
<div class="speaker-list__selection">
|
||||
<label class="speaker-list__field" for="speaker-{{ speaker_id }}-voice">
|
||||
<span>Assigned voice</span>
|
||||
<select id="speaker-{{ speaker_id }}-voice"
|
||||
name="speaker-{{ speaker_id }}-voice"
|
||||
data-role="speaker-voice"
|
||||
data-default-voice="{{ pending.voice }}">
|
||||
<option value="" {% if not selected_voice %}selected{% endif %}>Use narrator voice ({{ pending.voice }})</option>
|
||||
<option value="__custom_mix" data-role="custom-mix-option" {% if speaker.voice_formula %}selected{% else %}hidden disabled{% endif %}>
|
||||
Custom mix
|
||||
</option>
|
||||
{% if speaker.recommended_voices %}
|
||||
<optgroup label="Recommended">
|
||||
{% for voice_id in speaker.recommended_voices[:6] %}
|
||||
{% if voice_id not in seen.values %}
|
||||
{% set voice_meta = options.voice_catalog_map.get(voice_id) or {} %}
|
||||
<option value="{{ voice_id }}" {% if selected_voice == voice_id %}selected{% endif %}>
|
||||
{{ voice_meta.display_name or voice_id }} · {{ voice_meta.language_label or voice_id[0]|upper }} · {{ voice_meta.gender or 'Unknown' }}
|
||||
</option>
|
||||
{% set _ = seen.values.append(voice_id) %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</optgroup>
|
||||
{% endif %}
|
||||
<optgroup label="All voices">
|
||||
{% for voice in options.voice_catalog %}
|
||||
{% if voice.id not in seen.values %}
|
||||
<option value="{{ voice.id }}"
|
||||
{% if selected_voice == voice.id %}selected{% endif %}>
|
||||
{{ voice.display_name }} · {{ voice.language_label }} · {{ voice.gender }}
|
||||
</option>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</optgroup>
|
||||
</select>
|
||||
</label>
|
||||
<button type="button"
|
||||
class="button button--ghost button--small"
|
||||
data-role="open-voice-browser"
|
||||
data-speaker-id="{{ speaker_id }}">
|
||||
Browse voices
|
||||
</button>
|
||||
<button type="button"
|
||||
class="button button--ghost button--small"
|
||||
data-role="generate-voice"
|
||||
data-speaker-id="{{ speaker_id }}">
|
||||
Generate voice
|
||||
</button>
|
||||
<button type="button"
|
||||
class="button button--ghost button--small"
|
||||
data-role="speaker-preview"
|
||||
data-preview-kind="generated"
|
||||
data-speaker-id="{{ speaker_id }}"
|
||||
data-preview-source="generated"
|
||||
data-preview-text="{{ pronunciation_text|e }}"
|
||||
data-language="{{ pending.language }}"
|
||||
data-voice="{{ speaker.voice_formula or selected_voice or pending.voice }}"
|
||||
data-speed="{{ '%.2f'|format(pending.speed) }}"
|
||||
data-use-gpu="{{ 'true' if pending.use_gpu else 'false' }}"
|
||||
|
||||
Reference in New Issue
Block a user