feat: Implement pronunciation store with SQLite backend

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

refactor: Overhaul entities step in the preparation wizard

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

docs: Add detailed plan for entities step overhaul

- Documented requirements, implementation strategies, and testing plans for the entities step.
- Outlined the integration of POS tagging and entity recognition using spaCy.
- Provided a comprehensive overview of the manual overrides workflow and data persistence strategies.
This commit is contained in:
JB
2025-10-11 14:14:19 -07:00
parent 610091c0d9
commit 93fe48f601
13 changed files with 2440 additions and 300 deletions
+401
View File
@@ -0,0 +1,401 @@
from __future__ import annotations
import hashlib
import os
import re
import threading
import time
from collections import Counter
from dataclasses import dataclass, field
from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple
try: # pragma: no cover - fallback when spaCy not available during tests
import spacy # type: ignore[import-not-found]
except Exception: # pragma: no cover - spaCy optional during runtime bootstrap
spacy = None
_Language = Any # type: ignore[misc,assignment]
Doc = Any # type: ignore[misc,assignment]
Span = Any # type: ignore[misc,assignment]
_TITLE_PREFIXES = (
"mr",
"mrs",
"ms",
"miss",
"dr",
"prof",
"sir",
"madam",
"lady",
"lord",
"capt",
"captain",
"col",
"colonel",
"maj",
"major",
"sgt",
"sergeant",
"rev",
"father",
"mother",
"brother",
"sister",
)
_STOP_LABELS = {
"the",
"that",
"this",
"those",
"these",
"there",
"here",
"then",
"and",
"but",
"or",
"nor",
"so",
"yet",
}
_TITLE_PATTERN = re.compile(r"^(?:" + "|".join(re.escape(prefix) for prefix in _TITLE_PREFIXES) + r")\.?\s+", re.IGNORECASE)
_POSSESSIVE_PATTERN = re.compile(r"(?:'s|s|\u2019s)$", re.IGNORECASE)
_NON_WORD_PATTERN = re.compile(r"[^\w\s'-]+")
_MULTI_SPACE_PATTERN = re.compile(r"\s+")
@dataclass(slots=True)
class EntityRecord:
key: Tuple[str, str]
label: str
kind: str
category: str
count: int = 0
samples: List[Dict[str, Any]] = field(default_factory=list)
chapter_indices: set[int] = field(default_factory=set)
forms: Counter = field(default_factory=Counter)
first_position: Optional[Tuple[int, int]] = None
def register(self, *, chapter_index: int, position: int, text: str, sentence: Optional[str]) -> None:
self.count += 1
self.chapter_indices.add(chapter_index)
self.forms[text] += 1
if self.first_position is None:
self.first_position = (chapter_index, position)
if sentence and len(self.samples) < 5:
payload = {
"excerpt": sentence.strip(),
"chapter_index": chapter_index,
}
if payload not in self.samples:
self.samples.append(payload)
def as_dict(self, ordinal: int) -> Dict[str, Any]:
chapter_indices = sorted(self.chapter_indices)
first_chapter = chapter_indices[0] if chapter_indices else None
return {
"id": f"{self.category}_{ordinal}",
"label": self.label,
"normalized": self.key[1],
"category": self.category,
"kind": self.kind,
"count": self.count,
"samples": list(self.samples),
"chapter_indices": chapter_indices,
"first_chapter": first_chapter,
"forms": self.forms.most_common(6),
}
@dataclass(slots=True)
class EntityExtractionResult:
summary: Dict[str, Any]
cache_key: str
elapsed: float
errors: List[str]
class EntityModelError(RuntimeError):
pass
_MODEL_CACHE: Dict[str, Any] = {}
_MODEL_LOCK = threading.RLock()
def _resolve_model_name(language: str) -> str:
override = os.environ.get("ABOGEN_SPACY_MODEL")
if override:
return override.strip()
lowered = language.strip().lower()
if lowered.startswith("en"):
return "en_core_web_sm"
return "en_core_web_sm"
def _load_model(language: str) -> Any:
if spacy is None:
raise EntityModelError("spaCy is not available. Install spaCy to enable entity extraction.")
model_name = _resolve_model_name(language)
cache_key = model_name.lower()
with _MODEL_LOCK:
if cache_key in _MODEL_CACHE:
return _MODEL_CACHE[cache_key]
try:
nlp = spacy.load(model_name) # type: ignore[arg-type]
except OSError as exc: # pragma: no cover - external dependency failure
raise EntityModelError(
f"spaCy model '{model_name}' is not installed. Download it with "
"`python -m spacy download en_core_web_sm`."
) from exc
nlp.max_length = max(nlp.max_length, 2_000_000)
_MODEL_CACHE[cache_key] = nlp
return nlp
def _normalize_label(text: str) -> str:
if not text:
return ""
stripped = text.strip().strip("\"'`“”’")
if not stripped:
return ""
stripped = _TITLE_PATTERN.sub("", stripped)
stripped = _POSSESSIVE_PATTERN.sub("", stripped)
stripped = _NON_WORD_PATTERN.sub(" ", stripped)
stripped = _MULTI_SPACE_PATTERN.sub(" ", stripped)
stripped = stripped.strip()
if not stripped or stripped.lower() in _STOP_LABELS:
return ""
parts = stripped.split()
if not parts:
return ""
if len(parts) == 1 and len(parts[0]) <= 1:
return ""
# Normalise casing: preserve uppercase abbreviations, otherwise title case.
normalized_parts = []
for index, part in enumerate(parts):
if part.isupper():
normalized_parts.append(part)
elif part[:1].isupper():
normalized_parts.append(part[:1].upper() + part[1:])
elif index == 0:
normalized_parts.append(part[:1].upper() + part[1:])
else:
normalized_parts.append(part)
normalized = " ".join(normalized_parts).strip()
if normalized.lower() in _STOP_LABELS:
return ""
return normalized
def _token_key(value: str) -> str:
return _MULTI_SPACE_PATTERN.sub(" ", value.lower().strip()).strip()
def _iter_named_entities(doc: Any) -> Iterable[Any]: # type: ignore[override]
for ent in getattr(doc, "ents", ()):
if ent.label_ == "":
continue
yield ent
def _extract_propn_tokens(doc: Any) -> Iterable[Any]: # type: ignore[override]
seen: set[Tuple[int, int]] = set()
for ent in getattr(doc, "ents", ()): # guard multi-token spans
seen.add((ent.start, ent.end))
for token in doc:
if token.pos_ != "PROPN":
continue
span_key = (token.i, token.i + 1)
if span_key in seen:
continue
if token.is_stop:
continue
text = token.text.strip()
if not text:
continue
if token.ent_type_:
continue
yield doc[token.i : token.i + 1]
def _empty_result(cache_key: str, error: Optional[str] = None) -> EntityExtractionResult:
payload = {
"people": [],
"entities": [],
"index": {"tokens": []},
"stats": {
"tokens": 0,
"chapters": 0,
"processed": False,
},
"model": None,
}
errors = [error] if error else []
return EntityExtractionResult(summary=payload, cache_key=cache_key, elapsed=0.0, errors=errors)
def extract_entities(
chapters: Iterable[Mapping[str, Any]],
*,
language: str = "en",
) -> EntityExtractionResult:
start = time.perf_counter()
normalized_language = language or "en"
combined_hasher = hashlib.sha1()
chapter_texts: List[Tuple[int, str]] = []
for idx, chapter in enumerate(chapters):
text = chapter.get("text") if isinstance(chapter, Mapping) else None
text_value = str(text or "")
chapter_texts.append((idx, text_value))
if text_value:
combined_hasher.update(text_value.encode("utf-8", "ignore"))
cache_key = combined_hasher.hexdigest()
if not chapter_texts:
return _empty_result(cache_key)
try:
nlp = _load_model(normalized_language)
except EntityModelError as exc:
return _empty_result(cache_key, str(exc))
records: Dict[Tuple[str, str], EntityRecord] = {}
tokens_for_index: Dict[str, Dict[str, Any]] = {}
processed_tokens = 0
for chapter_index, text in chapter_texts:
trimmed = text.strip()
if not trimmed:
continue
if len(trimmed) + 1024 > nlp.max_length:
nlp.max_length = len(trimmed) + 1024
doc = nlp(trimmed)
def _register_span(span: Any, category_hint: Optional[str] = None) -> None:
nonlocal processed_tokens
cleaned = _normalize_label(span.text)
if not cleaned:
return
key = _token_key(cleaned)
if not key:
return
category = category_hint or ("people" if span.label_ == "PERSON" else "entities")
record_key = (category, key)
record = records.get(record_key)
if record is None:
record = EntityRecord(
key=record_key,
label=cleaned,
kind=span.label_ or ("PROPN" if category == "entities" else "PERSON"),
category=category,
)
records[record_key] = record
sentence = span.sent.text if hasattr(span, "sent") and span.sent is not None else None
record.register(
chapter_index=chapter_index,
position=span.start,
text=span.text,
sentence=sentence,
)
processed_tokens += 1
index_entry = tokens_for_index.get(key)
if index_entry is None:
index_entry = {
"token": record.label,
"normalized": key,
"category": category,
"count": 0,
"samples": [],
}
tokens_for_index[key] = index_entry
index_entry["count"] += 1
if sentence and len(index_entry["samples"]) < 3:
if sentence not in index_entry["samples"]:
index_entry["samples"].append(sentence)
for ent in _iter_named_entities(doc):
_register_span(ent)
for span in _extract_propn_tokens(doc):
_register_span(span, category_hint="entities")
elapsed = time.perf_counter() - start
people_records = [record for record in records.values() if record.category == "people"]
entity_records = [record for record in records.values() if record.category == "entities"]
people_records.sort(key=lambda rec: (-rec.count, rec.label))
entity_records.sort(key=lambda rec: (-rec.count, rec.label))
people_payload = [record.as_dict(index + 1) for index, record in enumerate(people_records)]
entity_payload = [record.as_dict(index + 1) for index, record in enumerate(entity_records)]
index_payload = sorted(tokens_for_index.values(), key=lambda item: (-item["count"], item["token"]))
summary = {
"people": people_payload,
"entities": entity_payload,
"index": {"tokens": index_payload},
"stats": {
"tokens": processed_tokens,
"chapters": len(chapter_texts),
"processed": True,
},
"model": {
"name": getattr(nlp, "meta", {}).get("name", "unknown"),
"version": getattr(nlp, "meta", {}).get("version", "unknown"),
"lang": getattr(nlp, "meta", {}).get("lang", normalized_language),
},
}
return EntityExtractionResult(summary=summary, cache_key=cache_key, elapsed=elapsed, errors=[])
def search_tokens(index: Mapping[str, Any], query: str, *, limit: int = 15) -> List[Dict[str, Any]]:
tokens = index.get("tokens") if isinstance(index, Mapping) else None
if not isinstance(tokens, list) or not query:
return []
normalized = query.strip().lower()
if not normalized:
return tokens[:limit]
results: List[Dict[str, Any]] = []
for entry in tokens:
token_label = str(entry.get("token", ""))
normalized_label = token_label.lower()
if normalized in normalized_label or normalized in str(entry.get("normalized", "")):
results.append(entry)
if len(results) >= limit:
break
return results
def merge_override(summary: Mapping[str, Any], overrides: Mapping[str, Mapping[str, Any]]) -> Dict[str, Any]:
if not isinstance(summary, Mapping):
return {"people": [], "entities": []}
merged_summary: Dict[str, Any] = dict(summary)
for key in ("people", "entities"):
items = summary.get(key)
if not isinstance(items, list):
continue
merged_items: List[Dict[str, Any]] = []
for entry in items:
if not isinstance(entry, Mapping):
continue
normalized = _token_key(str(entry.get("normalized") or entry.get("label") or ""))
merged = dict(entry)
if normalized and normalized in overrides:
merged_override = dict(overrides[normalized])
merged["override"] = merged_override
merged_items.append(merged)
merged_summary[key] = merged_items
return merged_summary
def normalize_token(token: str) -> str:
return _token_key(_normalize_label(token))
+228
View File
@@ -0,0 +1,228 @@
from __future__ import annotations
import sqlite3
import threading
import time
from pathlib import Path
from typing import Any, Dict, Iterable, List, Mapping, Optional
from .entity_analysis import normalize_token
from .utils import get_internal_cache_path, get_user_settings_dir
_DB_LOCK = threading.RLock()
_SCHEMA_VERSION = 1
def _store_path() -> Path:
try:
base_dir = Path(get_user_settings_dir())
except ModuleNotFoundError:
base_dir = Path(get_internal_cache_path("pronunciations"))
target = base_dir / "pronunciations.db"
target.parent.mkdir(parents=True, exist_ok=True)
return target
def _connect() -> sqlite3.Connection:
connection = sqlite3.connect(_store_path())
connection.execute("PRAGMA journal_mode=WAL")
connection.execute("PRAGMA foreign_keys=ON")
return connection
def _ensure_schema(conn: sqlite3.Connection) -> None:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS overrides (
id INTEGER PRIMARY KEY AUTOINCREMENT,
normalized TEXT NOT NULL,
token TEXT NOT NULL,
language TEXT NOT NULL,
pronunciation TEXT,
voice TEXT,
notes TEXT,
context TEXT,
usage_count INTEGER NOT NULL DEFAULT 0,
created_at REAL NOT NULL,
updated_at REAL NOT NULL,
UNIQUE(normalized, language)
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS metadata (
key TEXT PRIMARY KEY,
value INTEGER NOT NULL
)
"""
)
row = conn.execute("SELECT value FROM metadata WHERE key='schema_version'").fetchone()
if row is None:
conn.execute(
"INSERT OR REPLACE INTO metadata(key, value) VALUES('schema_version', ?)",
(_SCHEMA_VERSION,),
)
conn.commit()
def _row_to_dict(row: sqlite3.Row) -> Dict[str, Any]:
return {
"id": row["id"],
"normalized": row["normalized"],
"token": row["token"],
"language": row["language"],
"pronunciation": row["pronunciation"],
"voice": row["voice"],
"notes": row["notes"],
"context": row["context"],
"usage_count": row["usage_count"],
"created_at": row["created_at"],
"updated_at": row["updated_at"],
}
def load_overrides(language: str, tokens: Iterable[str]) -> Dict[str, Dict[str, Any]]:
normalized_tokens = {normalize_token(token) for token in tokens if token}
if not normalized_tokens:
return {}
placeholders = ",".join("?" for _ in normalized_tokens)
with _DB_LOCK:
conn = _connect()
try:
_ensure_schema(conn)
conn.row_factory = sqlite3.Row
cursor = conn.execute(
f"SELECT * FROM overrides WHERE language=? AND normalized IN ({placeholders})",
(language, *normalized_tokens),
)
results: Dict[str, Dict[str, Any]] = {}
for row in cursor.fetchall():
payload = _row_to_dict(row)
results[payload["normalized"]] = payload
return results
finally:
conn.close()
def search_overrides(language: str, query: str, *, limit: int = 15) -> List[Dict[str, Any]]:
if not query:
return []
pattern = f"%{query.lower()}%"
with _DB_LOCK:
conn = _connect()
try:
_ensure_schema(conn)
conn.row_factory = sqlite3.Row
cursor = conn.execute(
"""
SELECT * FROM overrides
WHERE language = ? AND (normalized LIKE ? OR LOWER(token) LIKE ?)
ORDER BY usage_count DESC, updated_at DESC
LIMIT ?
""",
(language, pattern, pattern, limit),
)
return [_row_to_dict(row) for row in cursor.fetchall()]
finally:
conn.close()
def save_override(
*,
language: str,
token: str,
pronunciation: Optional[str] = None,
voice: Optional[str] = None,
notes: Optional[str] = None,
context: Optional[str] = None,
) -> Dict[str, Any]:
normalized = normalize_token(token)
if not normalized:
raise ValueError("Provide a token to override")
timestamp = time.time()
with _DB_LOCK:
conn = _connect()
try:
_ensure_schema(conn)
conn.execute(
"""
INSERT INTO overrides (normalized, token, language, pronunciation, voice, notes, context, usage_count, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?)
ON CONFLICT(normalized, language) DO UPDATE SET
token=excluded.token,
pronunciation=excluded.pronunciation,
voice=excluded.voice,
notes=excluded.notes,
context=excluded.context,
updated_at=excluded.updated_at
""",
(
normalized,
token,
language,
pronunciation,
voice,
notes,
context,
timestamp,
timestamp,
),
)
conn.commit()
conn.row_factory = sqlite3.Row
row = conn.execute(
"SELECT * FROM overrides WHERE normalized=? AND language=?",
(normalized, language),
).fetchone()
if row is None: # pragma: no cover - defensive guard
raise RuntimeError("Override save failed")
return _row_to_dict(row)
finally:
conn.close()
def delete_override(*, language: str, token: str) -> None:
normalized = normalize_token(token)
if not normalized:
return
with _DB_LOCK:
conn = _connect()
try:
_ensure_schema(conn)
conn.execute("DELETE FROM overrides WHERE normalized=? AND language=?", (normalized, language))
conn.commit()
finally:
conn.close()
def all_overrides(language: str) -> List[Dict[str, Any]]:
with _DB_LOCK:
conn = _connect()
try:
_ensure_schema(conn)
conn.row_factory = sqlite3.Row
cursor = conn.execute(
"SELECT * FROM overrides WHERE language=? ORDER BY updated_at DESC",
(language,),
)
return [_row_to_dict(row) for row in cursor.fetchall()]
finally:
conn.close()
def increment_usage(*, language: str, token: str, amount: int = 1) -> None:
normalized = normalize_token(token)
if not normalized:
return
with _DB_LOCK:
conn = _connect()
try:
_ensure_schema(conn)
conn.execute(
"UPDATE overrides SET usage_count = usage_count + ?, updated_at = ? WHERE normalized=? AND language=?",
(amount, time.time(), normalized, language),
)
conn.commit()
finally:
conn.close()
+430 -25
View File
@@ -50,6 +50,18 @@ from abogen.utils import (
load_numpy_kpipeline,
save_config,
)
from abogen.entity_analysis import (
extract_entities,
merge_override,
normalize_token as normalize_entity_token,
search_tokens as search_entity_tokens,
)
from abogen.pronunciation_store import (
delete_override as delete_pronunciation_override,
load_overrides as load_pronunciation_overrides,
save_override as save_pronunciation_override,
search_overrides as search_pronunciation_overrides,
)
from abogen.voice_profiles import (
delete_profile,
duplicate_profile,
@@ -958,6 +970,318 @@ def _prepare_speaker_metadata(
return chunk_list, roster, analysis_payload, applied_languages, updated_config
def _collect_pronunciation_overrides(pending: PendingJob) -> List[Dict[str, Any]]:
language = pending.language or "en"
collected: Dict[str, Dict[str, Any]] = {}
summary = pending.entity_summary or {}
for group in ("people", "entities"):
entries = summary.get(group)
if not isinstance(entries, list):
continue
for entry in entries:
if not isinstance(entry, Mapping):
continue
override_payload = entry.get("override")
if not isinstance(override_payload, Mapping):
continue
token_value = str(entry.get("label") or override_payload.get("token") or "").strip()
pronunciation_value = str(override_payload.get("pronunciation") or "").strip()
if not token_value or not pronunciation_value:
continue
normalized = normalize_entity_token(entry.get("normalized") or token_value)
if not normalized:
continue
collected[normalized] = {
"token": token_value,
"normalized": normalized,
"pronunciation": pronunciation_value,
"voice": str(override_payload.get("voice") or "").strip() or None,
"notes": str(override_payload.get("notes") or "").strip() or None,
"context": str(override_payload.get("context") or "").strip() or None,
"source": f"{group}-override",
"language": language,
}
if isinstance(pending.speakers, Mapping):
for speaker_payload in pending.speakers.values():
if not isinstance(speaker_payload, Mapping):
continue
token_value = str(speaker_payload.get("label") or "").strip()
pronunciation_value = str(speaker_payload.get("pronunciation") or "").strip()
if not token_value or not pronunciation_value:
continue
normalized = normalize_entity_token(token_value)
if not normalized:
continue
collected[normalized] = {
"token": token_value,
"normalized": normalized,
"pronunciation": pronunciation_value,
"voice": str(
speaker_payload.get("resolved_voice")
or speaker_payload.get("voice")
or pending.voice
).strip()
or None,
"notes": None,
"context": None,
"source": "speaker",
"language": language,
}
for manual_entry in pending.manual_overrides or []:
if not isinstance(manual_entry, Mapping):
continue
token_value = str(manual_entry.get("token") or "").strip()
pronunciation_value = str(manual_entry.get("pronunciation") or "").strip()
if not token_value or not pronunciation_value:
continue
normalized = manual_entry.get("normalized") or normalize_entity_token(token_value)
if not normalized:
continue
collected[normalized] = {
"token": token_value,
"normalized": normalized,
"pronunciation": pronunciation_value,
"voice": str(manual_entry.get("voice") or "").strip() or None,
"notes": str(manual_entry.get("notes") or "").strip() or None,
"context": str(manual_entry.get("context") or "").strip() or None,
"source": str(manual_entry.get("source") or "manual"),
"language": language,
}
return list(collected.values())
def _sync_pronunciation_overrides(pending: PendingJob) -> None:
pending.pronunciation_overrides = _collect_pronunciation_overrides(pending)
if not pending.pronunciation_overrides:
return
summary = pending.entity_summary or {}
manual_map: Dict[str, Mapping[str, Any]] = {}
for override in pending.manual_overrides or []:
if not isinstance(override, Mapping):
continue
normalized = override.get("normalized") or normalize_entity_token(override.get("token") or "")
pronunciation_value = str(override.get("pronunciation") or "").strip()
if not normalized or not pronunciation_value:
continue
manual_map[normalized] = override
for group in ("people", "entities"):
entries = summary.get(group)
if not isinstance(entries, list):
continue
for entry in entries:
if not isinstance(entry, dict):
continue
normalized = normalize_entity_token(entry.get("normalized") or entry.get("label") or "")
manual_override = manual_map.get(normalized)
if manual_override:
entry["override"] = {
"token": manual_override.get("token"),
"pronunciation": manual_override.get("pronunciation"),
"voice": manual_override.get("voice"),
"notes": manual_override.get("notes"),
"context": manual_override.get("context"),
"source": manual_override.get("source"),
}
def _refresh_entity_summary(pending: PendingJob, chapters: Iterable[Mapping[str, Any]]) -> None:
language = pending.language or "en"
result = extract_entities(chapters, language=language)
summary = dict(result.summary)
tokens: List[str] = []
for group in ("people", "entities"):
entries = summary.get(group)
if not isinstance(entries, list):
continue
for entry in entries:
if not isinstance(entry, Mapping):
continue
token_value = str(entry.get("normalized") or entry.get("label") or "").strip()
if token_value:
tokens.append(token_value)
overrides_from_store = load_pronunciation_overrides(language=language, tokens=tokens)
merged_summary = merge_override(summary, overrides_from_store)
if result.errors:
merged_summary["errors"] = list(result.errors)
merged_summary["cache_key"] = result.cache_key
pending.entity_summary = merged_summary
pending.entity_cache_key = result.cache_key
_sync_pronunciation_overrides(pending)
def _find_manual_override(pending: PendingJob, identifier: str) -> Optional[Dict[str, Any]]:
for entry in pending.manual_overrides or []:
if not isinstance(entry, dict):
continue
if entry.get("id") == identifier or entry.get("normalized") == identifier:
return entry
return None
def _upsert_manual_override(pending: PendingJob, payload: Mapping[str, Any]) -> Dict[str, Any]:
token_value = str(payload.get("token") or "").strip()
if not token_value:
raise ValueError("Token is required")
pronunciation_value = str(payload.get("pronunciation") or "").strip()
voice_value = str(payload.get("voice") or "").strip()
notes_value = str(payload.get("notes") or "").strip()
context_value = str(payload.get("context") or "").strip()
normalized = payload.get("normalized") or normalize_entity_token(token_value)
if not normalized:
raise ValueError("Token is required")
existing = _find_manual_override(pending, payload.get("id", "")) or _find_manual_override(pending, normalized)
timestamp = time.time()
language = pending.language or "en"
if existing:
existing.update(
{
"token": token_value,
"normalized": normalized,
"pronunciation": pronunciation_value,
"voice": voice_value,
"notes": notes_value,
"context": context_value,
"updated_at": timestamp,
}
)
manual_entry = existing
else:
manual_entry = {
"id": payload.get("id") or uuid.uuid4().hex,
"token": token_value,
"normalized": normalized,
"pronunciation": pronunciation_value,
"voice": voice_value,
"notes": notes_value,
"context": context_value,
"language": language,
"source": payload.get("source") or "manual",
"created_at": timestamp,
"updated_at": timestamp,
}
if isinstance(pending.manual_overrides, list):
pending.manual_overrides.append(manual_entry)
else:
pending.manual_overrides = [manual_entry]
save_pronunciation_override(
language=language,
token=token_value,
pronunciation=pronunciation_value or None,
voice=voice_value or None,
notes=notes_value or None,
context=context_value or None,
)
_sync_pronunciation_overrides(pending)
return dict(manual_entry)
def _delete_manual_override(pending: PendingJob, override_id: str) -> bool:
if not override_id:
return False
entries = pending.manual_overrides or []
for index, entry in enumerate(entries):
if not isinstance(entry, dict):
continue
if entry.get("id") == override_id:
token_value = entry.get("token") or ""
language = pending.language or "en"
delete_pronunciation_override(language=language, token=token_value)
entries.pop(index)
pending.manual_overrides = entries
_sync_pronunciation_overrides(pending)
return True
return False
def _search_manual_override_candidates(pending: PendingJob, query: str, *, limit: int = 15) -> List[Dict[str, Any]]:
normalized_query = (query or "").strip()
summary_index = (pending.entity_summary or {}).get("index", {})
matches = search_entity_tokens(summary_index, normalized_query, limit=limit)
registry: Dict[str, Dict[str, Any]] = {}
for entry in matches:
normalized = normalize_entity_token(entry.get("normalized") or entry.get("token") or "")
if not normalized:
continue
registry.setdefault(
normalized,
{
"token": entry.get("token"),
"normalized": normalized,
"category": entry.get("category") or "entity",
"count": entry.get("count", 0),
"samples": entry.get("samples", []),
"source": "entity",
},
)
language = pending.language or "en"
store_matches = search_pronunciation_overrides(language=language, query=normalized_query, limit=limit)
for entry in store_matches:
normalized = entry.get("normalized")
if not normalized:
continue
registry.setdefault(
normalized,
{
"token": entry.get("token"),
"normalized": normalized,
"category": "history",
"count": entry.get("usage_count", 0),
"samples": [entry.get("context")] if entry.get("context") else [],
"source": "history",
"pronunciation": entry.get("pronunciation"),
"voice": entry.get("voice"),
},
)
for entry in pending.manual_overrides or []:
if not isinstance(entry, Mapping):
continue
normalized = entry.get("normalized")
if not normalized:
continue
registry.setdefault(
normalized,
{
"token": entry.get("token"),
"normalized": normalized,
"category": "manual",
"count": 0,
"samples": [entry.get("context")] if entry.get("context") else [],
"source": "manual",
"pronunciation": entry.get("pronunciation"),
"voice": entry.get("voice"),
},
)
ordered = sorted(registry.values(), key=lambda item: (-int(item.get("count") or 0), item.get("token") or ""))
if limit:
return ordered[:limit]
return ordered
def _pending_entities_payload(pending: PendingJob) -> Dict[str, Any]:
return {
"summary": pending.entity_summary or {},
"manual_overrides": pending.manual_overrides or [],
"pronunciation_overrides": pending.pronunciation_overrides or [],
"cache_key": pending.entity_cache_key,
"language": pending.language or "en",
}
def _apply_prepare_form(
pending: PendingJob, form: Mapping[str, Any]
) -> tuple[
@@ -1141,6 +1465,8 @@ def _apply_prepare_form(
enabled_overrides = [entry for entry in overrides if entry.get("enabled")]
_sync_pronunciation_overrides(pending)
return (
chunk_level_literal,
overrides,
@@ -1261,6 +1587,13 @@ def _service() -> ConversionService:
return current_app.extensions["conversion_service"]
def _require_pending_job(pending_id: str) -> PendingJob:
pending = _service().get_pending_job(pending_id)
if not pending:
abort(404)
return cast(PendingJob, pending)
def _build_voice_catalog() -> List[Dict[str, str]]:
catalog: List[Dict[str, str]] = []
gender_map = {"f": "Female", "m": "Male"}
@@ -2101,6 +2434,74 @@ def api_speaker_preview() -> ResponseReturnValue:
return response
@api_bp.get("/pending/<pending_id>/entities")
def api_pending_entities(pending_id: str) -> ResponseReturnValue:
pending = _require_pending_job(pending_id)
refresh_flag = (request.args.get("refresh") or "").strip().lower()
expected_cache = (request.args.get("cache_key") or "").strip()
refresh_requested = refresh_flag in {"1", "true", "yes", "force"}
if expected_cache and expected_cache != (pending.entity_cache_key or ""):
refresh_requested = True
if refresh_requested or not pending.entity_summary:
_refresh_entity_summary(pending, pending.chapters)
_service().store_pending_job(pending)
return jsonify(_pending_entities_payload(pending))
@api_bp.post("/pending/<pending_id>/entities/refresh")
def api_refresh_pending_entities(pending_id: str) -> ResponseReturnValue:
pending = _require_pending_job(pending_id)
_refresh_entity_summary(pending, pending.chapters)
_service().store_pending_job(pending)
return jsonify(_pending_entities_payload(pending))
@api_bp.get("/pending/<pending_id>/manual-overrides")
def api_list_manual_overrides(pending_id: str) -> ResponseReturnValue:
pending = _require_pending_job(pending_id)
return jsonify(
{
"overrides": pending.manual_overrides or [],
"pronunciation_overrides": pending.pronunciation_overrides or [],
"language": pending.language or "en",
}
)
@api_bp.post("/pending/<pending_id>/manual-overrides")
def api_upsert_manual_override(pending_id: str) -> ResponseReturnValue:
pending = _require_pending_job(pending_id)
payload = request.get_json(silent=True) or {}
if not isinstance(payload, Mapping):
abort(400, "Invalid override payload")
try:
override = _upsert_manual_override(pending, payload)
except ValueError as exc:
abort(400, str(exc))
_service().store_pending_job(pending)
return jsonify({"override": override, **_pending_entities_payload(pending)})
@api_bp.delete("/pending/<pending_id>/manual-overrides/<override_id>")
def api_delete_manual_override(pending_id: str, override_id: str) -> ResponseReturnValue:
pending = _require_pending_job(pending_id)
deleted = _delete_manual_override(pending, override_id)
if not deleted:
abort(404)
_service().store_pending_job(pending)
return jsonify({"deleted": True, **_pending_entities_payload(pending)})
@api_bp.get("/pending/<pending_id>/manual-overrides/search")
def api_search_manual_override_candidates(pending_id: str) -> ResponseReturnValue:
pending = _require_pending_job(pending_id)
query = (request.args.get("q") or request.args.get("query") or "").strip()
limit_param = request.args.get("limit")
limit_value = _coerce_int(limit_param, 15, minimum=1, maximum=50) if limit_param is not None else 15
results = _search_manual_override_candidates(pending, query, limit=limit_value)
return jsonify({"query": query, "limit": limit_value, "results": results})
@web_bp.post("/jobs")
def enqueue_job() -> ResponseReturnValue:
service = _service()
@@ -2310,6 +2711,8 @@ def enqueue_job() -> ResponseReturnValue:
analysis_requested=initial_analysis,
)
_refresh_entity_summary(pending, pending.chapters)
service.store_pending_job(pending)
pending.applied_speaker_config = selected_speaker_config or None
if config_languages:
@@ -2323,20 +2726,14 @@ def enqueue_job() -> ResponseReturnValue:
@web_bp.get("/jobs/prepare/<pending_id>")
def prepare_job(pending_id: str) -> str:
pending = _service().get_pending_job(pending_id)
if not pending:
abort(404)
pending = cast(PendingJob, pending)
pending = _require_pending_job(pending_id)
return _render_prepare_page(pending, active_step="chapters")
@web_bp.post("/jobs/prepare/<pending_id>/analyze")
def analyze_pending_job(pending_id: str) -> ResponseReturnValue:
service = _service()
pending = service.get_pending_job(pending_id)
if not pending:
abort(404)
pending = cast(PendingJob, pending)
pending = _require_pending_job(pending_id)
(
chunk_level_literal,
@@ -2412,21 +2809,20 @@ def analyze_pending_job(pending_id: str) -> ResponseReturnValue:
if selected_total:
pending.total_characters = selected_total
_sync_pronunciation_overrides(pending)
service.store_pending_job(pending)
notice_message = "Speaker analysis updated."
notice_message = "Entity insights updated."
if persist_config_requested and config_name:
notice_message = "Speaker analysis updated and configuration saved."
return _render_prepare_page(pending, notice=notice_message, active_step="speakers")
notice_message = "Entity insights updated and configuration saved."
return _render_prepare_page(pending, notice=notice_message, active_step="entities")
@web_bp.post("/jobs/prepare/<pending_id>")
def finalize_job(pending_id: str) -> ResponseReturnValue:
service = _service()
pending = service.get_pending_job(pending_id)
if not pending:
abort(404)
pending = cast(PendingJob, pending)
pending = _require_pending_job(pending_id)
(
chunk_level_literal,
@@ -2443,7 +2839,7 @@ def finalize_job(pending_id: str) -> ResponseReturnValue:
return _render_prepare_page(
pending,
error=" ".join(errors),
active_step=request.form.get("active_step") or "speakers",
active_step=request.form.get("active_step") or "entities",
)
if pending.speaker_mode != "multi":
@@ -2458,12 +2854,14 @@ def finalize_job(pending_id: str) -> ResponseReturnValue:
)
active_step = (request.form.get("active_step") or "chapters").strip().lower()
if active_step == "speakers":
active_step = "entities"
raw_chunks = build_chunks_for_chapters(enabled_overrides, level=chunk_level_literal)
analysis_chunks = build_chunks_for_chapters(enabled_overrides, level="sentence")
is_multi = pending.speaker_mode == "multi"
analysis_requested = bool(getattr(pending, "analysis_requested", False))
should_force_speakers = is_multi and active_step != "speakers"
should_force_entities = is_multi and active_step != "entities"
if analysis_requested:
existing_roster: Optional[Mapping[str, Any]] = pending.speakers
@@ -2477,7 +2875,7 @@ def finalize_job(pending_id: str) -> ResponseReturnValue:
config_name = pending.applied_speaker_config or selected_config
speaker_config_payload = get_config(config_name) if config_name else None
run_analysis = is_multi and (should_force_speakers or analysis_requested)
run_analysis = is_multi and (should_force_entities or analysis_requested)
processed_chunks, roster, analysis_payload, config_languages, updated_config = _prepare_speaker_metadata(
chapters=enabled_overrides,
chunks=raw_chunks,
@@ -2511,15 +2909,17 @@ def finalize_job(pending_id: str) -> ResponseReturnValue:
if selected_total:
pending.total_characters = selected_total
if should_force_speakers:
notice_message = "Review speaker assignments before queuing."
_sync_pronunciation_overrides(pending)
if should_force_entities:
notice_message = "Review entity settings before queuing."
if persist_config_requested and config_key:
notice_message = "Configuration saved. Review speaker assignments before queuing."
notice_message = "Configuration saved. Review entity settings before queuing."
service.store_pending_job(pending)
return _render_prepare_page(
pending,
notice=notice_message,
active_step="speakers",
active_step="entities",
)
total_characters = selected_total or pending.total_characters
@@ -2559,6 +2959,9 @@ def finalize_job(pending_id: str) -> ResponseReturnValue:
speaker_analysis=pending.speaker_analysis,
speaker_analysis_threshold=pending.speaker_analysis_threshold,
analysis_requested=getattr(pending, "analysis_requested", False),
entity_summary=pending.entity_summary,
manual_overrides=pending.manual_overrides,
pronunciation_overrides=pending.pronunciation_overrides,
)
if config_languages:
@@ -2620,14 +3023,16 @@ def _render_prepare_page(
) or "chapters"
normalized_step = (active_step or "chapters").strip().lower()
if normalized_step not in {"chapters", "speakers"}:
if normalized_step == "speakers":
normalized_step = "entities"
if normalized_step not in {"chapters", "entities"}:
normalized_step = "chapters"
is_multi = pending.speaker_mode == "multi"
if normalized_step == "speakers" and not is_multi:
if normalized_step == "entities" and not is_multi:
normalized_step = "chapters"
template_name = "prepare_speakers.html" if normalized_step == "speakers" else "prepare_chapters.html"
template_name = "prepare_entities.html" if normalized_step == "entities" else "prepare_chapters.html"
return render_template(
template_name,
+28 -3
View File
@@ -24,7 +24,7 @@ def _create_set_event() -> threading.Event:
return event
STATE_VERSION = 5
STATE_VERSION = 6
_JOB_LOGGER = logging.getLogger("abogen.jobs")
@@ -133,8 +133,9 @@ class Job:
analysis_requested: bool = False
speaker_voice_languages: List[str] = field(default_factory=list)
applied_speaker_config: Optional[str] = None
speaker_voice_languages: List[str] = field(default_factory=list)
applied_speaker_config: Optional[str] = None
entity_summary: Dict[str, Any] = field(default_factory=dict)
manual_overrides: List[Dict[str, Any]] = field(default_factory=list)
pronunciation_overrides: List[Dict[str, Any]] = field(default_factory=list)
def add_log(self, message: str, level: str = "info") -> None:
entry = JobLog(timestamp=time.time(), message=message, level=level)
@@ -197,6 +198,9 @@ class Job:
"analysis_requested": self.analysis_requested,
"speaker_voice_languages": list(self.speaker_voice_languages),
"applied_speaker_config": self.applied_speaker_config,
"entity_summary": dict(self.entity_summary),
"manual_overrides": [dict(entry) for entry in self.manual_overrides],
"pronunciation_overrides": [dict(entry) for entry in self.pronunciation_overrides],
}
@@ -239,6 +243,10 @@ class PendingJob:
analysis_requested: bool = False
speaker_voice_languages: List[str] = field(default_factory=list)
applied_speaker_config: Optional[str] = None
entity_summary: Dict[str, Any] = field(default_factory=dict)
manual_overrides: List[Dict[str, Any]] = field(default_factory=list)
pronunciation_overrides: List[Dict[str, Any]] = field(default_factory=list)
entity_cache_key: Optional[str] = None
class ConversionService:
@@ -311,6 +319,9 @@ class ConversionService:
speaker_analysis: Optional[Mapping[str, Any]] = None,
speaker_analysis_threshold: int = 3,
analysis_requested: bool = False,
entity_summary: Optional[Mapping[str, Any]] = None,
manual_overrides: Optional[Iterable[Mapping[str, Any]]] = None,
pronunciation_overrides: Optional[Iterable[Mapping[str, Any]]] = None,
) -> Job:
job_id = uuid.uuid4().hex
normalized_metadata = self._normalize_metadata_tags(metadata_tags)
@@ -354,6 +365,9 @@ class ConversionService:
speaker_analysis=dict(speaker_analysis or {}),
speaker_analysis_threshold=int(speaker_analysis_threshold or 3),
analysis_requested=bool(analysis_requested),
entity_summary=dict(entity_summary or {}),
manual_overrides=[dict(entry) for entry in manual_overrides] if manual_overrides else [],
pronunciation_overrides=[dict(entry) for entry in pronunciation_overrides] if pronunciation_overrides else [],
)
with self._lock:
self._jobs[job_id] = job
@@ -505,6 +519,9 @@ class ConversionService:
speaker_analysis=job.speaker_analysis,
speaker_analysis_threshold=job.speaker_analysis_threshold,
analysis_requested=job.analysis_requested,
entity_summary=job.entity_summary,
manual_overrides=job.manual_overrides,
pronunciation_overrides=job.pronunciation_overrides,
)
new_job.speaker_voice_languages = list(job.speaker_voice_languages)
@@ -727,6 +744,9 @@ class ConversionService:
"speaker_analysis": dict(job.speaker_analysis),
"speaker_analysis_threshold": job.speaker_analysis_threshold,
"analysis_requested": job.analysis_requested,
"entity_summary": dict(job.entity_summary),
"manual_overrides": [dict(entry) for entry in job.manual_overrides],
"pronunciation_overrides": [dict(entry) for entry in job.pronunciation_overrides],
}
def _persist_state(self) -> None:
@@ -844,6 +864,11 @@ class ConversionService:
payload.get("speaker_analysis_threshold", job.speaker_analysis_threshold or 3)
)
job.analysis_requested = bool(payload.get("analysis_requested", job.analysis_requested))
job.entity_summary = payload.get("entity_summary", {})
job.manual_overrides = [dict(entry) for entry in payload.get("manual_overrides", []) if isinstance(entry, Mapping)]
job.pronunciation_overrides = [
dict(entry) for entry in payload.get("pronunciation_overrides", []) if isinstance(entry, Mapping)
]
job.pause_event.set()
return job
+608 -1
View File
@@ -450,7 +450,7 @@ document.addEventListener("DOMContentLoaded", () => {
analysisButtons.forEach((button) => {
button.addEventListener("click", () => {
if (activeStepInput) {
activeStepInput.value = "speakers";
activeStepInput.value = "entities";
}
});
});
@@ -1082,6 +1082,613 @@ document.addEventListener("DOMContentLoaded", () => {
renderVoiceList(elements);
}
const entitySummaryData = parseJSONScript("entity-summary-data") || {};
const entityCacheKeyData = parseJSONScript("entity-cache-key");
const manualOverridesSeed = parseJSONScript("manual-overrides-data") || [];
const pronunciationOverridesSeed = parseJSONScript("pronunciation-overrides-data") || [];
const entityTabs = form.querySelector('[data-role="entity-tabs"]');
const entitiesUrl = form.dataset.entitiesUrl || "";
const manualUpsertUrl = form.dataset.manualUpsertUrl || "";
const manualDeleteUrlTemplate = form.dataset.manualDeleteUrlTemplate || "";
const manualSearchUrl = form.dataset.manualSearchUrl || "";
const baseVoice = form.dataset.baseVoice || form.dataset.voice || "";
const languageCode = form.dataset.language || "en";
const defaultSpeed = form.dataset.speed || "1.0";
const useGpuDefault = form.dataset.useGpu || "true";
const entityState = {
summary: entitySummaryData && typeof entitySummaryData === "object" ? entitySummaryData : {},
cacheKey: typeof entityCacheKeyData === "string" ? entityCacheKeyData : "",
manualOverrides: Array.isArray(manualOverridesSeed) ? [...manualOverridesSeed] : [],
pronunciationOverrides: Array.isArray(pronunciationOverridesSeed) ? [...pronunciationOverridesSeed] : [],
};
let highlightedOverrideId = "";
const pendingOverrideSaves = new Map();
if (entityTabs) {
const tabButtons = Array.from(entityTabs.querySelectorAll('[data-role="entity-tab"]'));
const tabPanels = new Map(
Array.from(entityTabs.querySelectorAll('[data-role="entity-panel"]')).map((panel) => [panel.dataset.panel || "", panel])
);
const entitySummaryContainer = entityTabs.querySelector('[data-role="entity-summary"]');
const entityStatsNode = entitySummaryContainer?.querySelector('[data-role="entity-stats"]');
const entityListNode = entitySummaryContainer?.querySelector('[data-role="entity-list"]');
const entityRowTemplate = entitySummaryContainer?.querySelector('template[data-role="entity-row-template"]');
const entitiesRefreshButton = entitySummaryContainer?.querySelector('[data-role="entities-refresh"]');
const manualOverridesRoot = entityTabs.querySelector('[data-role="manual-overrides"]');
const manualOverrideList = manualOverridesRoot?.querySelector('[data-role="manual-override-list"]');
const manualOverrideTemplate = manualOverridesRoot?.querySelector('template[data-role="manual-override-template"]');
const manualOverrideResultsList = manualOverridesRoot?.querySelector('[data-role="manual-override-results"]');
const manualOverrideQueryInput = manualOverridesRoot?.querySelector('[data-role="manual-override-query"]');
const manualOverrideSearchButton = manualOverridesRoot?.querySelector('[data-role="manual-override-search"]');
const manualOverrideAddCustomButton = manualOverridesRoot?.querySelector('[data-role="manual-override-add-custom"]');
const manualOverridesEmpty = manualOverridesRoot?.querySelector('[data-role="manual-overrides-empty"]');
const cloneTemplate = (template) => {
if (!template) return null;
if (template.content && template.content.firstElementChild) {
return template.content.firstElementChild.cloneNode(true);
}
return template.cloneNode(true);
};
const formatMentions = (value) => {
const count = Number(value || 0);
return `${count.toLocaleString()} mention${count === 1 ? "" : "s"}`;
};
function activateEntityTab(panelKey) {
tabButtons.forEach((button) => {
const isActive = button.dataset.panel === panelKey;
button.classList.toggle("is-active", isActive);
button.setAttribute("aria-selected", isActive ? "true" : "false");
});
tabPanels.forEach((panel, key) => {
if (!panel) return;
const isActive = key === panelKey;
panel.classList.toggle("is-active", isActive);
panel.hidden = !isActive;
panel.setAttribute("aria-hidden", isActive ? "false" : "true");
});
}
function populateVoiceOptions(select, selectedVoice) {
if (!select) return;
const narratorLabel = baseVoice ? `Use narrator voice (${baseVoice})` : "Use narrator voice";
select.innerHTML = "";
const narratorOption = document.createElement("option");
narratorOption.value = "";
narratorOption.textContent = narratorLabel;
if (!selectedVoice) {
narratorOption.selected = true;
}
select.appendChild(narratorOption);
voiceCatalog.forEach((voice) => {
const option = document.createElement("option");
option.value = voice.id;
option.textContent = `${voice.display_name} · ${voice.language_label} · ${voice.gender}`;
if (selectedVoice === voice.id) {
option.selected = true;
}
select.appendChild(option);
});
if (selectedVoice) {
const hasMatch = Array.from(select.options).some((option) => option.value === selectedVoice);
if (!hasMatch) {
const fallback = document.createElement("option");
fallback.value = selectedVoice;
fallback.textContent = selectedVoice;
fallback.selected = true;
select.appendChild(fallback);
}
}
}
function renderEntitySummary() {
if (!entityListNode) return;
const summary = entityState.summary || {};
const stats = summary.stats || {};
const errors = Array.isArray(summary.errors) ? summary.errors : [];
if (entityStatsNode) {
if (errors.length) {
entityStatsNode.textContent = errors.join(" · ");
} else if (stats.processed) {
const parts = [];
if (typeof stats.chapters === "number") {
parts.push(`${stats.chapters} chapter${stats.chapters === 1 ? "" : "s"}`);
}
if (typeof stats.tokens === "number") {
parts.push(`${stats.tokens.toLocaleString()} tokens processed`);
}
entityStatsNode.textContent = parts.length ? parts.join(" · ") : "Entity analysis up to date.";
} else {
entityStatsNode.textContent = "Entity analysis will populate once you continue from chapters.";
}
}
entityListNode.innerHTML = "";
const entries = Array.isArray(summary.entities) ? summary.entities : [];
if (!entries.length) {
const emptyItem = document.createElement("li");
emptyItem.className = "entity-summary__empty";
emptyItem.textContent = "No entities detected yet.";
entityListNode.appendChild(emptyItem);
return;
}
entries.slice(0, 120).forEach((entity) => {
const item = cloneTemplate(entityRowTemplate);
if (!item) return;
const normalized = entity.normalized || entity.label || entity.token || "";
const tokenLabel = entity.label || entity.token || normalized || "Untitled entity";
item.dataset.entityId = entity.id || normalized || tokenLabel;
const labelEl = item.querySelector('[data-role="entity-label"]');
if (labelEl) {
labelEl.textContent = tokenLabel;
}
const kindEl = item.querySelector('[data-role="entity-kind"]');
if (kindEl) {
const kind = entity.kind || entity.category || "";
kindEl.textContent = kind;
kindEl.hidden = !kind;
}
const countEl = item.querySelector('[data-role="entity-count"]');
if (countEl) {
countEl.textContent = formatMentions(entity.count);
}
const samplesContainer = item.querySelector('[data-role="entity-samples"]');
if (samplesContainer) {
samplesContainer.innerHTML = "";
const samples = Array.isArray(entity.samples) ? entity.samples : [];
if (!samples.length) {
const hint = document.createElement("p");
hint.className = "hint";
hint.textContent = "No sample sentences captured yet.";
samplesContainer.appendChild(hint);
} else {
const list = document.createElement("ul");
list.className = "entity-summary__samples-list";
samples.slice(0, 3).forEach((sample) => {
const text = typeof sample === "string" ? sample : sample?.excerpt;
if (!text) return;
const entry = document.createElement("li");
entry.textContent = text;
list.appendChild(entry);
});
samplesContainer.appendChild(list);
}
}
const button = item.querySelector('[data-role="entity-add-override"]');
if (button) {
const hasOverride = entityState.manualOverrides.some((entry) => {
const candidate = entry?.normalized || entry?.token || "";
return candidate && candidate.toLowerCase() === normalized.toLowerCase();
});
button.dataset.entityToken = entity.label || entity.token || "";
button.dataset.entityNormalized = normalized;
const sampleContext = Array.isArray(entity.samples) && entity.samples.length
? typeof entity.samples[0] === "string"
? entity.samples[0]
: entity.samples[0]?.excerpt || ""
: "";
if (sampleContext) {
button.dataset.entityContext = sampleContext;
}
button.textContent = hasOverride ? "Edit manual override" : "Add manual override";
}
entityListNode.appendChild(item);
});
}
function renderManualOverrides() {
if (!manualOverrideList) return;
manualOverrideList.innerHTML = "";
const overrides = Array.isArray(entityState.manualOverrides) ? entityState.manualOverrides : [];
if (!overrides.length) {
if (manualOverridesEmpty) {
manualOverridesEmpty.hidden = false;
}
return;
}
if (manualOverridesEmpty) {
manualOverridesEmpty.hidden = true;
}
overrides.forEach((override) => {
const node = cloneTemplate(manualOverrideTemplate);
if (!node) return;
const overrideId = override.id || override.normalized || override.token || "";
node.dataset.overrideId = overrideId;
node.dataset.token = override.token || "";
node.dataset.normalized = override.normalized || "";
node.dataset.context = override.context || "";
const labelEl = node.querySelector('[data-role="override-label"]');
if (labelEl) {
labelEl.textContent = override.token || override.normalized || "Manual override";
}
const notesEl = node.querySelector('[data-role="override-notes"]');
if (notesEl) {
const notes = override.notes || override.context || "";
if (notes) {
notesEl.textContent = notes;
} else {
notesEl.hidden = true;
}
}
const pronInput = node.querySelector('[data-role="manual-override-pronunciation"]');
if (pronInput) {
pronInput.value = override.pronunciation || "";
pronInput.placeholder = override.token || override.normalized || "";
pronInput.dataset.overrideId = overrideId;
}
const voiceSelect = node.querySelector('[data-role="manual-override-voice"]');
if (voiceSelect) {
populateVoiceOptions(voiceSelect, override.voice || "");
voiceSelect.dataset.overrideId = overrideId;
}
const previewButton = node.querySelector('[data-role="speaker-preview"]');
if (previewButton) {
const previewVoice = override.voice || voiceSelect?.value || baseVoice || "";
const previewText = override.pronunciation || override.token || "";
previewButton.dataset.overrideId = overrideId;
previewButton.dataset.previewText = previewText;
previewButton.dataset.voice = previewVoice;
previewButton.dataset.language = languageCode;
previewButton.dataset.speed = defaultSpeed;
previewButton.dataset.useGpu = useGpuDefault;
}
const deleteButton = node.querySelector('[data-role="manual-override-delete"]');
if (deleteButton) {
deleteButton.dataset.overrideId = overrideId;
}
const metaEl = node.querySelector('[data-role="manual-override-meta"]');
if (metaEl) {
const parts = [];
if (override.source) {
parts.push(`Source: ${override.source}`);
}
if (override.updated_at) {
const timestamp = Number(override.updated_at) * 1000;
if (!Number.isNaN(timestamp)) {
parts.push(`Updated ${new Date(timestamp).toLocaleString()}`);
}
}
metaEl.textContent = parts.join(" · ");
}
manualOverrideList.appendChild(node);
if (highlightedOverrideId && highlightedOverrideId === overrideId) {
node.classList.add("is-highlighted");
setTimeout(() => node.classList.remove("is-highlighted"), 2400);
node.scrollIntoView({ behavior: "smooth", block: "center" });
highlightedOverrideId = "";
}
});
}
function applyEntityPayload(payload, options = {}) {
if (payload && typeof payload === "object") {
if (payload.summary) {
entityState.summary = payload.summary;
}
if (Array.isArray(payload.manual_overrides)) {
entityState.manualOverrides = payload.manual_overrides;
}
if (Array.isArray(payload.pronunciation_overrides)) {
entityState.pronunciationOverrides = payload.pronunciation_overrides;
}
if (typeof payload.cache_key === "string") {
entityState.cacheKey = payload.cache_key;
}
}
if (options.highlightId) {
highlightedOverrideId = options.highlightId;
}
renderEntitySummary();
renderManualOverrides();
}
function collectOverridePayload(overrideId) {
if (!overrideId || !manualOverrideList) return null;
const selectorId = typeof CSS !== "undefined" && CSS.escape ? CSS.escape(overrideId) : overrideId.replace(/["\\]/g, "\\$&");
const node = manualOverrideList.querySelector(`[data-override-id="${selectorId}"]`);
if (!node) return null;
const pronInput = node.querySelector('[data-role="manual-override-pronunciation"]');
const voiceSelect = node.querySelector('[data-role="manual-override-voice"]');
return {
id: overrideId,
token: node.dataset.token || "",
normalized: node.dataset.normalized || "",
pronunciation: pronInput?.value?.trim() || "",
voice: voiceSelect?.value || "",
notes: "",
context: node.dataset.context || "",
};
}
async function saveManualOverride(overrideId) {
const payload = collectOverridePayload(overrideId);
if (!payload || !manualUpsertUrl) return;
try {
const response = await fetch(manualUpsertUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(`Override save failed with status ${response.status}`);
}
const data = await response.json();
applyEntityPayload(data, { highlightId: data.override?.id || payload.id });
} catch (error) {
console.error("Failed to save manual override", error);
}
}
function scheduleManualOverrideSave(overrideId) {
if (!overrideId) return;
if (pendingOverrideSaves.has(overrideId)) {
clearTimeout(pendingOverrideSaves.get(overrideId));
}
const timer = setTimeout(() => {
pendingOverrideSaves.delete(overrideId);
saveManualOverride(overrideId);
}, 400);
pendingOverrideSaves.set(overrideId, timer);
}
async function deleteManualOverride(overrideId) {
if (!overrideId || !manualDeleteUrlTemplate) return;
const targetUrl = manualDeleteUrlTemplate.replace("__OVERRIDE_ID__", encodeURIComponent(overrideId));
try {
const response = await fetch(targetUrl, { method: "DELETE" });
if (!response.ok) {
throw new Error(`Override delete failed with status ${response.status}`);
}
const data = await response.json();
applyEntityPayload(data);
} catch (error) {
console.error("Failed to delete manual override", error);
}
}
async function performEntitiesRefresh(force = false) {
if (!entitiesUrl) return;
try {
const url = new URL(entitiesUrl, window.location.origin);
if (force) {
url.searchParams.set("refresh", "1");
}
if (entityState.cacheKey) {
url.searchParams.set("cache_key", entityState.cacheKey);
}
const response = await fetch(url.toString(), { method: "GET" });
if (!response.ok) {
throw new Error(`Entity refresh failed with status ${response.status}`);
}
const data = await response.json();
applyEntityPayload(data);
} catch (error) {
console.error("Failed to refresh entity summary", error);
}
}
async function performManualOverrideSearch(query) {
if (!manualSearchUrl || !manualOverrideResultsList) return;
manualOverrideResultsList.innerHTML = "";
try {
const url = new URL(manualSearchUrl, window.location.origin);
if (query) {
url.searchParams.set("q", query);
}
const response = await fetch(url.toString(), { method: "GET" });
if (!response.ok) {
throw new Error(`Search failed with status ${response.status}`);
}
const data = await response.json();
const results = Array.isArray(data.results) ? data.results : [];
if (!results.length) {
const emptyItem = document.createElement("li");
emptyItem.className = "manual-overrides__results-empty";
emptyItem.textContent = query ? "No matches found." : "Start typing to search tokens.";
manualOverrideResultsList.appendChild(emptyItem);
return;
}
results.forEach((entry) => {
const item = document.createElement("li");
item.className = "manual-overrides__result";
const button = document.createElement("button");
button.type = "button";
button.className = "button button--ghost button--small";
button.dataset.role = "manual-override-result";
button.dataset.token = entry.token || entry.normalized || "";
if (entry.normalized) {
button.dataset.normalized = entry.normalized;
}
if (entry.context) {
button.dataset.context = entry.context;
} else if (Array.isArray(entry.samples) && entry.samples.length) {
const sample = entry.samples[0];
button.dataset.context = typeof sample === "string" ? sample : sample?.excerpt || "";
}
if (entry.pronunciation) {
button.dataset.pronunciation = entry.pronunciation;
}
if (entry.voice) {
button.dataset.voice = entry.voice;
}
button.dataset.source = entry.source || "search";
button.textContent = entry.token || entry.normalized || "Unnamed token";
item.appendChild(button);
manualOverrideResultsList.appendChild(item);
});
} catch (error) {
console.error("Failed to search manual override candidates", error);
}
}
async function createOverrideFromData(data) {
if (!data || !manualUpsertUrl) return;
const token = (data.token || "").trim();
if (!token) return;
const payload = {
token,
normalized: (data.normalized || "").trim(),
context: data.context || "",
pronunciation: data.pronunciation || "",
voice: data.voice || "",
source: data.source || "manual",
};
try {
const response = await fetch(manualUpsertUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(`Override creation failed with status ${response.status}`);
}
const body = await response.json();
const highlighted = body.override?.id || payload.normalized || payload.token;
applyEntityPayload(body, { highlightId: highlighted });
activateEntityTab("manual");
if (manualOverrideResultsList) {
manualOverrideResultsList.innerHTML = "";
}
} catch (error) {
console.error("Failed to create manual override", error);
}
}
tabButtons.forEach((button) => {
button.addEventListener("click", () => {
activateEntityTab(button.dataset.panel || "people");
});
});
if (entitiesRefreshButton) {
entitiesRefreshButton.addEventListener("click", () => {
performEntitiesRefresh(true);
});
}
if (entityListNode) {
entityListNode.addEventListener("click", (event) => {
const trigger = event.target.closest('[data-role="entity-add-override"]');
if (!trigger) return;
event.preventDefault();
createOverrideFromData({
token: trigger.dataset.entityToken || "",
normalized: trigger.dataset.entityNormalized || "",
context: trigger.dataset.entityContext || "",
source: "entity",
});
});
}
if (manualOverrideSearchButton) {
manualOverrideSearchButton.addEventListener("click", () => {
const query = manualOverrideQueryInput?.value?.trim() || "";
performManualOverrideSearch(query);
});
}
if (manualOverrideQueryInput) {
manualOverrideQueryInput.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
performManualOverrideSearch(manualOverrideQueryInput.value.trim());
}
});
}
if (manualOverrideAddCustomButton) {
manualOverrideAddCustomButton.addEventListener("click", () => {
const token = window.prompt("Enter the token to override:");
if (!token) return;
createOverrideFromData({ token: token.trim(), source: "manual" });
});
}
if (manualOverrideResultsList) {
manualOverrideResultsList.addEventListener("click", (event) => {
const resultButton = event.target.closest('[data-role="manual-override-result"]');
if (!resultButton) return;
event.preventDefault();
createOverrideFromData({
token: resultButton.dataset.token || "",
normalized: resultButton.dataset.normalized || "",
context: resultButton.dataset.context || "",
pronunciation: resultButton.dataset.pronunciation || "",
voice: resultButton.dataset.voice || "",
source: resultButton.dataset.source || "search",
});
});
}
if (manualOverrideList) {
manualOverrideList.addEventListener("input", (event) => {
const input = event.target.closest('[data-role="manual-override-pronunciation"]');
if (!input) return;
const overrideId = input.dataset.overrideId || input.closest('[data-override-id]')?.dataset.overrideId;
const container = input.closest(".manual-override");
const previewButton = container?.querySelector('[data-role="speaker-preview"]');
if (previewButton) {
previewButton.dataset.previewText = input.value.trim() || input.placeholder || "";
}
if (overrideId) {
scheduleManualOverrideSave(overrideId);
}
});
manualOverrideList.addEventListener("change", (event) => {
const select = event.target.closest('[data-role="manual-override-voice"]');
if (!select) return;
const overrideId = select.dataset.overrideId || select.closest('[data-override-id]')?.dataset.overrideId;
const container = select.closest(".manual-override");
const previewButton = container?.querySelector('[data-role="speaker-preview"]');
if (previewButton) {
previewButton.dataset.voice = select.value || baseVoice || select.dataset.defaultVoice || "";
}
if (overrideId) {
scheduleManualOverrideSave(overrideId);
}
});
manualOverrideList.addEventListener("click", (event) => {
const deleteButton = event.target.closest('[data-role="manual-override-delete"]');
if (!deleteButton) return;
event.preventDefault();
const overrideId = deleteButton.dataset.overrideId;
if (!overrideId) return;
deleteManualOverride(overrideId);
});
}
const initialTab = tabButtons.find((button) => button.classList.contains("is-active"));
activateEntityTab(initialTab?.dataset.panel || "people");
renderEntitySummary();
renderManualOverrides();
}
form.addEventListener("click", (event) => {
const genderMenu = event.target.closest('[data-role="gender-menu"]');
const genderPill = event.target.closest('[data-role="gender-pill"]');
+1 -1
View File
@@ -5,7 +5,7 @@
{% block content %}
<section class="card card--workflow">
<h1 class="card__title">Create a New Audiobook</h1>
<p class="card__subtitle">Kick off a fresh conversion with your manuscript or pasted text. You can fine-tune chapters and speakers in the next steps.</p>
<p class="card__subtitle">Kick off a fresh conversion with your manuscript or pasted text. You can fine-tune chapters and entities in the next steps.</p>
<div class="upload-card__dropzone" data-role="upload-dropzone" tabindex="0" role="button" aria-label="Open upload settings or drop a manuscript file">
<div class="upload-card__dropzone-content">
<span class="upload-card__icon" aria-hidden="true">&uarr;</span>
@@ -42,9 +42,9 @@
{% set narrator_voice = options.voices[0] %}
{% endif %}
{% set speed_display = '%.2f'|format(narrator_speed) %}
{% set step1_state = 'is-active' if modal_step in ['settings', 'upload'] else ('is-complete' if modal_step in ['chapters', 'speakers'] else '') %}
{% set step2_state = 'is-active' if modal_step == 'chapters' else ('is-complete' if modal_step == 'speakers' else '') %}
{% set step3_state = 'is-active' if modal_step == 'speakers' else '' %}
{% set step1_state = 'is-active' if modal_step in ['settings', 'upload'] else ('is-complete' if modal_step in ['chapters', 'entities'] else '') %}
{% set step2_state = 'is-active' if modal_step == 'chapters' else ('is-complete' if modal_step == 'entities' else '') %}
{% set step3_state = 'is-active' if modal_step == 'entities' else '' %}
<div class="modal" data-role="upload-modal" hidden>
<div class="modal__overlay" data-role="upload-modal-close" tabindex="-1"></div>
<div class="modal__content card card--modal wizard-card" role="dialog" aria-modal="true" aria-labelledby="upload-modal-title">
@@ -62,7 +62,7 @@
{% if is_multi %}
<span class="step-indicator__item {{ step3_state }}">
<span class="step-indicator__index">3</span>
<span class="step-indicator__label">Speakers</span>
<span class="step-indicator__label">Entities</span>
</span>
{% endif %}
</nav>
@@ -177,7 +177,7 @@
</section>
<section class="form-section">
<h3 class="form-section__title">Speakers &amp; casting</h3>
<h3 class="form-section__title">Entities &amp; casting</h3>
<div class="form-section__layout">
<div class="field-grid field-grid--two">
<div class="field field--stack">
@@ -212,7 +212,7 @@
<div class="field">
<label for="speaker_analysis_threshold">Speaker analysis minimum mentions</label>
<input type="number" min="1" max="25" id="speaker_analysis_threshold" name="speaker_analysis_threshold" value="{{ analysis_threshold_value }}" {{ 'disabled' if readonly else '' }}>
<p class="hint">Speakers appearing less often fall back to the narrator voice.</p>
<p class="hint">Entities appearing less often fall back to the narrator voice.</p>
</div>
</div>
<div class="field">
+4 -4
View File
@@ -27,15 +27,15 @@
</a>
{% if is_multi_speaker %}
<a class="step-indicator__item"
href="{{ url_for('web.prepare_job', pending_id=pending.id, step='speakers') }}">
href="{{ url_for('web.prepare_job', pending_id=pending.id, step='entities') }}">
<span class="step-indicator__index">3</span>
<span class="step-indicator__label">Speakers</span>
<span class="step-indicator__label">Entities</span>
</a>
{% endif %}
</nav>
<p class="modal__eyebrow">Step 2 of {{ total_steps }}</p>
<h2 class="modal__title" id="prepare-chapters-title">Select chapters</h2>
<p class="hint">Choose which chapters to convert. We'll analyse speakers automatically when you continue.</p>
<p class="hint">Choose which chapters to convert. We'll analyse entities automatically when you continue.</p>
</div>
<div class="wizard-card__aside">
<p class="wizard-card__filename" title="{{ pending.original_filename }}">{{ pending.original_filename }}</p>
@@ -168,7 +168,7 @@
data-step-toggle="analysis"
formaction="{{ url_for('web.analyze_pending_job', pending_id=pending.id) }}"
formmethod="post">
Continue to speakers
Continue to entities
</button>
{% endif %}
<button type="submit" class="button" data-step-toggle="finalize"{% if is_multi_speaker %} hidden aria-hidden="true"{% endif %}>
+580
View File
@@ -0,0 +1,580 @@
{% extends "base.html" %}
{% block title %}Prepare · {{ pending.original_filename }}{% endblock %}
{% set is_multi_speaker = pending.speaker_mode == 'multi' %}
{% set analysis = pending.speaker_analysis or {} %}
{% set analysis_speakers = analysis.get('speakers', {}) %}
{% set speaker_configs = options.speaker_configs or [] %}
{% set recommended_template = options.speaker_pronunciation_sentence or "This is {{name}} speaking." %}
{% set total_steps = 3 if is_multi_speaker else 2 %}
{% block content %}
<section class="wizard-page wizard-page--modal" data-step="entities">
<div class="modal modal--wizard" data-role="wizard-modal" data-open="true">
<div class="modal__overlay" aria-hidden="true"></div>
<div class="modal__content card card--modal wizard-card" role="dialog" aria-modal="true" aria-labelledby="prepare-entities-title">
<header class="modal__header wizard-card__header">
<div class="wizard-card__headline">
<nav class="step-indicator" aria-label="Audiobook workflow">
<button type="button"
class="step-indicator__item is-complete"
data-role="open-upload-modal">
<span class="step-indicator__index">1</span>
<span class="step-indicator__label">Upload &amp; settings</span>
</button>
<a class="step-indicator__item is-complete"
href="{{ url_for('web.prepare_job', pending_id=pending.id, step='chapters') }}">
<span class="step-indicator__index">2</span>
<span class="step-indicator__label">Chapters</span>
</a>
{% if is_multi_speaker %}
<a class="step-indicator__item is-active"
aria-current="step"
href="{{ url_for('web.prepare_job', pending_id=pending.id, step='entities') }}">
<span class="step-indicator__index">3</span>
<span class="step-indicator__label">Entities</span>
</a>
{% endif %}
</nav>
<p class="modal__eyebrow">Step 3 of {{ total_steps }}</p>
<h2 class="modal__title" id="prepare-entities-title">Review entities</h2>
<p class="hint">Assign voices, tune pronunciations, and curate manual overrides before queueing the conversion.</p>
</div>
<div class="wizard-card__aside">
<p class="wizard-card__filename" title="{{ pending.original_filename }}">{{ pending.original_filename }}</p>
</div>
</header>
<form method="post"
action="{{ url_for('web.finalize_job', pending_id=pending.id) }}"
class="prepare-form"
id="prepare-form"
data-speaker-mode="{{ pending.speaker_mode }}"
data-analyze-url="{{ url_for('web.analyze_pending_job', pending_id=pending.id) }}"
data-entities-url="{{ url_for('api.api_pending_entities', pending_id=pending.id) }}"
data-manual-list-url="{{ url_for('api.api_list_manual_overrides', pending_id=pending.id) }}"
data-manual-upsert-url="{{ url_for('api.api_upsert_manual_override', pending_id=pending.id) }}"
data-manual-delete-url-template="{{ url_for('api.api_delete_manual_override', pending_id=pending.id, override_id='__OVERRIDE_ID__') }}"
data-manual-search-url="{{ url_for('api.api_search_manual_override_candidates', pending_id=pending.id) }}"
data-language="{{ pending.language }}"
data-base-voice="{{ pending.voice }}"
data-speed="{{ '%.2f'|format(pending.speed) }}"
data-use-gpu="{{ 'true' if pending.use_gpu else 'false' }}">
<input type="hidden" name="active_step" value="entities" data-role="active-step-input">
<div class="wizard-hidden-inputs" aria-hidden="true">
<input type="hidden" name="chunk_level" value="{{ pending.chunk_level }}">
<input type="hidden" name="speaker_mode" value="{{ pending.speaker_mode }}">
<input type="hidden" name="speaker_analysis_threshold" value="{{ pending.speaker_analysis_threshold }}">
<input type="hidden" name="chapter_intro_delay" value="{{ '%.2f'|format(pending.chapter_intro_delay) }}">
{% if pending.generate_epub3 %}
<input type="hidden" name="generate_epub3" value="true">
{% endif %}
{% for chapter in pending.chapters %}
{% set selected_option = '__default' %}
{% if chapter.voice_profile %}
{% set selected_option = 'profile:' ~ chapter.voice_profile %}
{% elif chapter.voice %}
{% set selected_option = 'voice:' ~ chapter.voice %}
{% elif chapter.voice_formula %}
{% set selected_option = 'formula' %}
{% endif %}
{% if chapter.enabled %}
<input type="hidden" name="chapter-{{ loop.index0 }}-enabled" value="on">
{% endif %}
<input type="hidden" name="chapter-{{ loop.index0 }}-title" value="{{ chapter.title }}">
<input type="hidden" name="chapter-{{ loop.index0 }}-voice" value="{{ selected_option }}">
{% if chapter.voice_formula %}
<input type="hidden" name="chapter-{{ loop.index0 }}-formula" value="{{ chapter.voice_formula }}">
{% endif %}
{% endfor %}
</div>
<div class="modal__body wizard-card__body">
{% if error %}
<div class="alert alert--error">{{ error }}</div>
{% endif %}
{% if notice %}
<div class="alert alert--info">{{ notice }}</div>
{% elif pending.speaker_mode != 'multi' %}
<div class="alert alert--info">Multi-speaker mode is disabled. Switch back to chapters to enable additional voices.</div>
{% endif %}
<div class="entity-tabs" data-role="entity-tabs">
<div class="entity-tabs__nav" role="tablist" aria-label="Entity categories">
<button type="button"
class="entity-tabs__tab is-active"
data-role="entity-tab"
data-panel="people"
id="entity-tab-people"
aria-controls="entity-panel-people"
aria-selected="true"
role="tab">
People
</button>
<button type="button"
class="entity-tabs__tab"
data-role="entity-tab"
data-panel="entities"
id="entity-tab-entities"
aria-controls="entity-panel-entities"
aria-selected="false"
role="tab">
Entities
</button>
<button type="button"
class="entity-tabs__tab"
data-role="entity-tab"
data-panel="manual"
id="entity-tab-manual"
aria-controls="entity-panel-manual"
aria-selected="false"
role="tab">
Manual Overrides
</button>
</div>
<div class="entity-tabs__panels">
<section class="entity-tabs__panel is-active"
data-role="entity-panel"
data-panel="people"
id="entity-panel-people"
role="tabpanel"
aria-labelledby="entity-tab-people">
<section class="prepare-speaker-config">
<div class="prepare-speaker-config__header">
<h2>Speaker configuration</h2>
<p class="hint">Reuse saved presets to keep character voices consistent between projects.</p>
</div>
<div class="prepare-speaker-config__grid">
<label class="field prepare-speaker-config__field" for="applied_speaker_config">
<span>Saved preset</span>
<select id="applied_speaker_config" name="applied_speaker_config">
<option value="">None</option>
{% for config in speaker_configs %}
<option value="{{ config.name }}" {% if pending.applied_speaker_config == config.name %}selected{% endif %}>
{{ config.name }} · {{ config.speakers|length }} speaker{% if config.speakers|length != 1 %}s{% endif %}
</option>
{% endfor %}
</select>
<p class="hint">Presets are managed on the <a href="{{ url_for('web.speaker_configs_page') }}">Speakers page</a>.</p>
</label>
<div class="prepare-speaker-config__actions">
<button type="submit"
class="button button--ghost"
name="apply_speaker_config"
data-role="submit-speaker-analysis"
value="1"
formaction="{{ url_for('web.analyze_pending_job', pending_id=pending.id) }}"
formmethod="post"
formnovalidate
{% if not speaker_configs %}disabled{% endif %}>
Apply preset
</button>
<label class="toggle-pill">
<input type="checkbox" name="save_speaker_config" value="1">
<span>Save roster updates back to preset</span>
</label>
</div>
</div>
</section>
{% if pending.speakers %}
<div class="prepare-speakers">
<h2>Speaker settings</h2>
<p class="hint">Set pronunciations, lock specific voices, and audition sample paragraphs to hear casting choices.</p>
<ul class="speaker-list">
{% for speaker_id, speaker in pending.speakers.items() %}
{% set pronunciation_text = speaker.pronunciation or speaker.label %}
{% set selected_voice = speaker.resolved_voice or speaker.voice %}
{% set seen = namespace(values=[]) %}
{% set sample_quotes = speaker.sample_quotes or [] %}
{% set detected_gender = speaker.detected_gender or speaker.gender or 'unknown' %}
{% set current_gender = speaker.gender or detected_gender %}
{% set gender_label = 'Either' if current_gender == 'either' else (current_gender|title if current_gender != 'unknown' else 'Unknown') %}
{% set detected_label = 'Either' if detected_gender == 'either' else (detected_gender|title if detected_gender != 'unknown' else 'Unknown') %}
<li class="speaker-list__item"
data-speaker-id="{{ speaker_id }}"
data-default-pronunciation="{{ pronunciation_text }}">
<div class="speaker-line speaker-list__header">
<span class="speaker-list__name">{{ speaker.label }}</span>
<button type="button"
class="icon-button speaker-list__preview"
data-role="speaker-preview"
data-speaker-id="{{ speaker_id }}"
data-preview-source="pronunciation"
data-preview-text="{{ pronunciation_text|e }}"
data-language="{{ pending.language }}"
data-voice="{{ selected_voice or pending.voice }}"
data-speed="{{ '%.2f'|format(pending.speed) }}"
data-use-gpu="{{ 'true' if pending.use_gpu else 'false' }}"
aria-label="Preview pronunciation for {{ speaker.label }}"
title="Preview pronunciation">
🔊
</button>
</div>
<template data-role="speaker-samples">{{ sample_quotes | tojson }}</template>
<div class="speaker-list__meta">
<div class="speaker-gender" data-role="speaker-gender" data-speaker-id="{{ speaker_id }}">
<button type="button"
class="chip speaker-gender__pill"
data-role="gender-pill"
data-current="{{ current_gender }}">
{{ gender_label }} voice
</button>
<div class="speaker-gender__menu" data-role="gender-menu" hidden>
<p class="hint">Detected: {{ detected_label }}</p>
<div class="speaker-gender__options">
<button type="button" class="chip" data-role="gender-option" data-value="female">Female</button>
<button type="button" class="chip" data-role="gender-option" data-value="male">Male</button>
<button type="button" class="chip" data-role="gender-option" data-value="either">Either</button>
<button type="button" class="chip" data-role="gender-option" data-value="{{ detected_gender }}" {% if detected_gender == current_gender %}data-state="active"{% endif %}>
Use detected ({{ detected_label }})
</button>
</div>
</div>
<input type="hidden" name="speaker-{{ speaker_id }}-gender" value="{{ current_gender }}" data-role="gender-input">
<input type="hidden" name="speaker-{{ speaker_id }}-detected-gender" value="{{ detected_gender }}">
</div>
{% if speaker.get('analysis_count') %}
<span class="badge badge--muted">{{ speaker.analysis_count }} lines · {{ speaker.analysis_confidence|default('low')|title }} confidence</span>
{% endif %}
</div>
<label class="speaker-list__field" for="speaker-{{ speaker_id }}-pronunciation">
<span>Pronunciation</span>
<input type="text"
id="speaker-{{ speaker_id }}-pronunciation"
name="speaker-{{ speaker_id }}-pronunciation"
value="{{ pronunciation_text }}"
data-role="speaker-pronunciation"
placeholder="{{ speaker.label }}">
</label>
<div class="speaker-list__controls">
<div class="speaker-list__selection">
<label class="speaker-list__field" for="speaker-{{ speaker_id }}-voice">
<span>Assigned voice</span>
<select id="speaker-{{ speaker_id }}-voice"
name="speaker-{{ speaker_id }}-voice"
data-role="speaker-voice"
data-default-voice="{{ pending.voice }}">
<option value="" {% if not selected_voice %}selected{% endif %}>Use narrator voice ({{ pending.voice }})</option>
<option value="__custom_mix" data-role="custom-mix-option" {% if speaker.voice_formula %}selected{% else %}hidden disabled{% endif %}>
Custom mix
</option>
{% if speaker.recommended_voices %}
<optgroup label="Recommended">
{% for voice_id in speaker.recommended_voices[:6] %}
{% if voice_id not in seen.values %}
{% set voice_meta = options.voice_catalog_map.get(voice_id) or {} %}
<option value="{{ voice_id }}" {% if selected_voice == voice_id %}selected{% endif %}>
{{ voice_meta.display_name or voice_id }} · {{ voice_meta.language_label or voice_id[0]|upper }} · {{ voice_meta.gender or 'Unknown' }}
</option>
{% set _ = seen.values.append(voice_id) %}
{% endif %}
{% endfor %}
</optgroup>
{% endif %}
<optgroup label="All voices">
{% for voice in options.voice_catalog %}
{% if voice.id not in seen.values %}
<option value="{{ voice.id }}"
{% if selected_voice == voice.id %}selected{% endif %}>
{{ voice.display_name }} · {{ voice.language_label }} · {{ voice.gender }}
</option>
{% endif %}
{% endfor %}
</optgroup>
</select>
</label>
<button type="button"
class="button button--ghost button--small"
data-role="open-voice-browser"
data-speaker-id="{{ speaker_id }}">
Browse voices
</button>
<button type="button"
class="button button--ghost button--small"
data-role="generate-voice"
data-speaker-id="{{ speaker_id }}">
Generate voice
</button>
<button type="button"
class="button button--ghost button--small"
data-role="speaker-preview"
data-preview-kind="generated"
data-speaker-id="{{ speaker_id }}"
data-preview-source="generated"
data-preview-text="{{ pronunciation_text|e }}"
data-language="{{ pending.language }}"
data-voice="{{ speaker.voice_formula or selected_voice or pending.voice }}"
data-speed="{{ '%.2f'|format(pending.speed) }}"
data-use-gpu="{{ 'true' if pending.use_gpu else 'false' }}"
{% if not speaker.voice_formula %}hidden{% endif %}>
Preview generated
</button>
</div>
<div class="speaker-list__mix" data-role="speaker-mix" {% if not speaker.voice_formula %}hidden{% endif %}>
<span class="tag">Custom mix</span>
<span data-role="speaker-mix-label">{{ speaker.voice_formula or '' }}</span>
<div class="speaker-list__mix-actions">
<button type="button"
class="button button--ghost button--small"
data-role="speaker-preview"
data-preview-source="mix"
data-speaker-id="{{ speaker_id }}"
data-preview-text="{{ pronunciation_text|e }}"
data-language="{{ pending.language }}"
data-voice="{{ speaker.voice_formula or selected_voice or pending.voice }}"
data-speed="{{ '%.2f'|format(pending.speed) }}"
data-use-gpu="{{ 'true' if pending.use_gpu else 'false' }}">
Preview mix
</button>
<button type="button" class="button button--ghost button--small" data-role="clear-mix">Clear</button>
</div>
</div>
<input type="hidden" name="speaker-{{ speaker_id }}-formula" value="{{ speaker.voice_formula or '' }}" data-role="speaker-formula">
</div>
<details class="speaker-list__samples" {% if not sample_quotes %}data-state="empty"{% endif %}>
<summary>Sample paragraphs</summary>
{% if sample_quotes %}
{% set first_sample = sample_quotes[0] if sample_quotes|length > 0 else None %}
{% set first_excerpt = first_sample.excerpt if first_sample is mapping else first_sample %}
{% set first_hint = first_sample.gender_hint if first_sample is mapping else '' %}
<article class="speaker-sample" data-role="speaker-sample">
<p data-role="sample-text">{{ first_excerpt }}</p>
<p class="hint" data-role="sample-hint" {% if not first_hint %}hidden{% endif %}>{{ first_hint }}</p>
<div class="speaker-sample__actions">
<button type="button"
class="button button--ghost button--small"
data-role="speaker-preview"
data-preview-source="sample"
data-speaker-id="{{ speaker_id }}"
data-preview-text="{{ first_excerpt }}"
data-language="{{ pending.language }}"
data-voice="{{ selected_voice or pending.voice }}"
data-speed="{{ '%.2f'|format(pending.speed) }}"
data-use-gpu="{{ 'true' if pending.use_gpu else 'false' }}">
Preview with assigned voice
</button>
<button type="button"
class="button button--ghost button--small"
data-role="open-voice-browser"
data-speaker-id="{{ speaker_id }}"
data-sample-index="0">
Preview in voice browser
</button>
{% if sample_quotes|length > 1 %}
<button type="button"
class="button button--ghost button--small"
data-role="speaker-next-sample">
Show another example
</button>
{% endif %}
</div>
</article>
{% else %}
<p class="hint">No paragraphs captured yet. Continue from Step 2 to gather dialogue samples automatically.</p>
{% endif %}
</details>
{% if speaker.recommended_voices %}
<div class="speaker-list__recommendations">
<span class="hint">Suggested:</span>
{% for voice_id in speaker.recommended_voices[:6] %}
{% set voice_meta = options.voice_catalog_map.get(voice_id) or {} %}
<button type="button"
class="chip"
data-role="recommended-voice"
data-voice="{{ voice_id }}"
title="{{ voice_meta.display_name or voice_id }} · {{ voice_meta.language_label or voice_id[0]|upper }} · {{ voice_meta.gender or 'Unknown' }}">
{{ voice_meta.display_name or voice_id }}
</button>
{% endfor %}
</div>
{% endif %}
</li>
{% endfor %}
</ul>
</div>
{% else %}
<p class="hint">No additional speakers detected yet. The narrator voice will be used for all dialogue.</p>
{% endif %}
</section>
<section class="entity-tabs__panel"
data-role="entity-panel"
data-panel="entities"
id="entity-panel-entities"
role="tabpanel"
aria-labelledby="entity-tab-entities"
hidden>
<div class="entity-summary" data-role="entity-summary">
<header class="entity-summary__header">
<h2>Entity insights</h2>
<div class="entity-summary__actions">
<button type="button" class="button button--ghost" data-role="entities-refresh">Refresh</button>
<button type="button" class="button button--ghost" data-role="entities-download" hidden>Download CSV</button>
</div>
</header>
<p class="hint">Review organisations, locations, and other proper nouns detected in the manuscript. Add manual overrides to customise pronunciations where needed.</p>
<div class="entity-summary__stats" data-role="entity-stats"></div>
<ul class="entity-summary__list" data-role="entity-list"></ul>
<template data-role="entity-row-template">
<li class="entity-summary__item" data-entity-id="">
<header class="entity-summary__item-head">
<div>
<span class="entity-summary__label" data-role="entity-label"></span>
<span class="entity-summary__kind" data-role="entity-kind"></span>
</div>
<div class="entity-summary__meta">
<span class="badge badge--muted" data-role="entity-count"></span>
<button type="button" class="button button--ghost button--small" data-role="entity-add-override">Add manual override</button>
</div>
</header>
<div class="entity-summary__samples" data-role="entity-samples"></div>
</li>
</template>
</div>
</section>
<section class="entity-tabs__panel"
data-role="entity-panel"
data-panel="manual"
id="entity-panel-manual"
role="tabpanel"
aria-labelledby="entity-tab-manual"
hidden>
<div class="manual-overrides" data-role="manual-overrides">
<header class="manual-overrides__header">
<h2>Manual overrides</h2>
<p class="hint">Search tokens from the book or add custom entries. Set pronunciations and assign voices to ensure previews and conversions use your preferred delivery.</p>
</header>
<div class="manual-overrides__search">
<label class="field" for="manual-override-query">
<span>Search manuscript tokens</span>
<input type="search" id="manual-override-query" data-role="manual-override-query" placeholder="Search by name or phrase">
</label>
<div class="manual-overrides__search-actions">
<button type="button" class="button button--ghost" data-role="manual-override-search">Search</button>
<button type="button" class="button button--ghost" data-role="manual-override-add-custom">Add custom token</button>
</div>
<ul class="manual-overrides__results" data-role="manual-override-results"></ul>
</div>
<div class="manual-overrides__list" data-role="manual-override-list"></div>
<template data-role="manual-override-template">
<article class="manual-override" data-override-id="">
<header class="manual-override__header">
<div>
<h3 class="manual-override__label" data-role="override-label"></h3>
<p class="manual-override__notes" data-role="override-notes"></p>
</div>
<div class="manual-override__actions">
<button type="button" class="button button--ghost button--small" data-role="speaker-preview" data-preview-source="manual">Preview</button>
<button type="button" class="button button--ghost button--small" data-role="manual-override-delete">Remove</button>
</div>
</header>
<div class="manual-override__body">
<label class="field" data-role="manual-override-pronunciation-label">
<span>Pronunciation</span>
<input type="text" data-role="manual-override-pronunciation" value="">
</label>
<label class="field">
<span>Assigned voice</span>
<select data-role="manual-override-voice" data-default-voice="{{ pending.voice }}">
<option value="">Use narrator voice ({{ pending.voice }})</option>
</select>
</label>
<div class="manual-override__meta" data-role="manual-override-meta"></div>
</div>
</article>
</template>
<p class="manual-overrides__empty" data-role="manual-overrides-empty" hidden>No overrides yet. Use search or add a custom entry to begin.</p>
</div>
</section>
</div>
</div>
</div>
<footer class="modal__footer wizard-card__footer">
<div class="wizard-card__footer-actions">
<a class="button button--ghost" href="{{ url_for('web.prepare_job', pending_id=pending.id, step='chapters') }}">Previous</a>
<button type="submit" class="button button--ghost" form="cancel-form">Cancel</button>
</div>
<div class="wizard-card__footer-actions">
<button type="submit" class="button">Queue conversion</button>
</div>
</footer>
</form>
<form method="post" action="{{ url_for('web.cancel_pending_job', pending_id=pending.id) }}" id="cancel-form"></form>
<div class="modal" data-role="voice-modal" hidden>
<div class="modal__overlay" data-role="voice-modal-close" tabindex="-1"></div>
<div class="modal__content voice-browser" role="dialog" aria-modal="true" aria-labelledby="voice-modal-title">
<header class="voice-browser__header">
<h2 id="voice-modal-title">Choose a voice</h2>
<button type="button" class="icon-button" data-role="voice-modal-close" aria-label="Close voice browser"></button>
</header>
<div class="voice-browser__body">
<aside class="voice-browser__filters">
<label class="field">
<span>Search</span>
<input type="search" data-role="voice-modal-search" placeholder="Search by name or language">
</label>
<label class="field">
<span>Language</span>
<select data-role="voice-modal-language">
<option value="">All languages</option>
{% for code, label in options.languages.items() %}
<option value="{{ code }}">{{ label }} ({{ code|upper }})</option>
{% endfor %}
</select>
</label>
<div class="voice-browser__gender" role="group" aria-label="Filter by gender">
<button type="button" class="button button--ghost button--small" data-role="voice-modal-gender" data-value="">All</button>
<button type="button" class="button button--ghost button--small" data-role="voice-modal-gender" data-value="f">Female</button>
<button type="button" class="button button--ghost button--small" data-role="voice-modal-gender" data-value="m">Male</button>
</div>
<button type="button" class="button button--ghost" data-role="voice-modal-clear">Clear selection</button>
</aside>
<section class="voice-browser__catalog" aria-label="Voice catalog">
<ul class="voice-browser__list" data-role="voice-modal-list"></ul>
</section>
<section class="voice-browser__mix" data-role="voice-modal-mix">
<header class="voice-browser__mix-header">
<h3>Selected voices</h3>
<p class="tag" data-role="voice-modal-mix-total">Total weight: 0.00</p>
</header>
<div class="voice-browser__mix-list" data-role="voice-modal-mix-list"></div>
<section class="voice-browser__preview" data-role="voice-modal-preview">
<h3 data-role="voice-modal-selected-name">Select a voice to preview</h3>
<p class="tag" data-role="voice-modal-selected-meta"></p>
<div class="voice-browser__samples" data-role="voice-modal-samples"></div>
<div class="voice-browser__actions">
<button type="button" class="button" data-role="voice-modal-apply" disabled>Apply mix</button>
</div>
</section>
</section>
</div>
</div>
</div>
</div>
</div>
</section>
{% with pending=pending, readonly=True, active_step='entities' %}
{% include "partials/upload_modal.html" %}
{% endwith %}
{% endblock %}
{% block scripts %}
{{ super() }}
<script id="voice-sample-texts" type="application/json">{{ options.sample_voice_texts | tojson }}</script>
<script id="voice-catalog-data" type="application/json">{{ options.voice_catalog | tojson }}</script>
<script id="voice-language-map" type="application/json">{{ options.languages | tojson }}</script>
<script id="entity-summary-data" type="application/json">{{ pending.entity_summary or {} | tojson }}</script>
<script id="entity-cache-key" type="application/json">{{ pending.entity_cache_key or '' | tojson }}</script>
<script id="manual-overrides-data" type="application/json">{{ pending.manual_overrides or [] | tojson }}</script>
<script id="pronunciation-overrides-data" type="application/json">{{ pending.pronunciation_overrides or [] | tojson }}</script>
<script type="module" src="{{ url_for('static', filename='speakers.js') }}"></script>
<script type="module" src="{{ url_for('static', filename='prepare.js') }}"></script>
<script type="module" src="{{ url_for('static', filename='dashboard.js') }}"></script>
{% endblock %}
+1 -1
View File
@@ -1,3 +1,3 @@
{# This template is intentionally left empty.
Step-specific templates now live in `prepare_chapters.html` and `prepare_speakers.html`.
Step-specific templates now live in `prepare_chapters.html` and `prepare_entities.html`.
The file is kept as a placeholder to avoid breaking documentation references. #}
-259
View File
@@ -1,262 +1,3 @@
{% extends "base.html" %}
{% block title %}Prepare · {{ pending.original_filename }}{% endblock %}
{% set is_multi_speaker = pending.speaker_mode == 'multi' %}
{% set analysis = pending.speaker_analysis or {} %}
{% set analysis_speakers = analysis.get('speakers', {}) %}
{% set speaker_configs = options.speaker_configs or [] %}
{% set recommended_template = options.speaker_pronunciation_sentence or "This is {{name}} speaking." %}
{% set total_steps = 3 if is_multi_speaker else 2 %}
{% block content %}
<section class="wizard-page wizard-page--modal" data-step="speakers">
<div class="modal modal--wizard" data-role="wizard-modal" data-open="true">
<div class="modal__overlay" aria-hidden="true"></div>
<div class="modal__content card card--modal wizard-card" role="dialog" aria-modal="true" aria-labelledby="prepare-speakers-title">
<header class="modal__header wizard-card__header">
<div class="wizard-card__headline">
<nav class="step-indicator" aria-label="Audiobook workflow">
<button type="button"
class="step-indicator__item is-complete"
data-role="open-upload-modal">
<span class="step-indicator__index">1</span>
<span class="step-indicator__label">Upload &amp; settings</span>
</button>
<a class="step-indicator__item is-complete"
href="{{ url_for('web.prepare_job', pending_id=pending.id, step='chapters') }}">
<span class="step-indicator__index">2</span>
<span class="step-indicator__label">Chapters</span>
</a>
{% if is_multi_speaker %}
<a class="step-indicator__item is-active"
aria-current="step"
href="{{ url_for('web.prepare_job', pending_id=pending.id, step='speakers') }}">
<span class="step-indicator__index">3</span>
<span class="step-indicator__label">Speakers</span>
</a>
{% endif %}
</nav>
<p class="modal__eyebrow">Step 3 of {{ total_steps }}</p>
<h2 class="modal__title" id="prepare-speakers-title">Select speakers</h2>
<p class="hint">Assign voices, audition samples, and finalise your roster before queueing the conversion.</p>
</div>
<div class="wizard-card__aside">
<p class="wizard-card__filename" title="{{ pending.original_filename }}">{{ pending.original_filename }}</p>
</div>
</header>
<form method="post"
action="{{ url_for('web.finalize_job', pending_id=pending.id) }}"
class="prepare-form"
id="prepare-form"
data-speaker-mode="{{ pending.speaker_mode }}"
data-analyze-url="{{ url_for('web.analyze_pending_job', pending_id=pending.id) }}">
<input type="hidden" name="active_step" value="speakers" data-role="active-step-input">
<div class="wizard-hidden-inputs" aria-hidden="true">
<input type="hidden" name="chunk_level" value="{{ pending.chunk_level }}">
<input type="hidden" name="speaker_mode" value="{{ pending.speaker_mode }}">
<input type="hidden" name="speaker_analysis_threshold" value="{{ pending.speaker_analysis_threshold }}">
<input type="hidden" name="chapter_intro_delay" value="{{ '%.2f'|format(pending.chapter_intro_delay) }}">
{% if pending.generate_epub3 %}
<input type="hidden" name="generate_epub3" value="true">
{% endif %}
{% for chapter in pending.chapters %}
{% set selected_option = '__default' %}
{% if chapter.voice_profile %}
{% set selected_option = 'profile:' ~ chapter.voice_profile %}
{% elif chapter.voice %}
{% set selected_option = 'voice:' ~ chapter.voice %}
{% elif chapter.voice_formula %}
{% set selected_option = 'formula' %}
{% endif %}
{% if chapter.enabled %}
<input type="hidden" name="chapter-{{ loop.index0 }}-enabled" value="on">
{% endif %}
<input type="hidden" name="chapter-{{ loop.index0 }}-title" value="{{ chapter.title }}">
<input type="hidden" name="chapter-{{ loop.index0 }}-voice" value="{{ selected_option }}">
{% if chapter.voice_formula %}
<input type="hidden" name="chapter-{{ loop.index0 }}-formula" value="{{ chapter.voice_formula }}">
{% endif %}
{% endfor %}
</div>
<div class="modal__body wizard-card__body">
{% if error %}
<div class="alert alert--error">{{ error }}</div>
{% endif %}
{% if notice %}
<div class="alert alert--info">{{ notice }}</div>
{% elif pending.speaker_mode != 'multi' %}
<div class="alert alert--info">Multi-speaker mode is disabled. Switch back to chapters to enable additional voices.</div>
{% endif %}
<section class="prepare-speaker-config">
<div class="prepare-speaker-config__header">
<h2>Speaker configuration</h2>
<p class="hint">Reuse saved presets to keep character voices consistent between projects.</p>
</div>
<div class="prepare-speaker-config__grid">
<label class="field prepare-speaker-config__field" for="applied_speaker_config">
<span>Saved preset</span>
<select id="applied_speaker_config" name="applied_speaker_config">
<option value="">None</option>
{% for config in speaker_configs %}
<option value="{{ config.name }}" {% if pending.applied_speaker_config == config.name %}selected{% endif %}>
{{ config.name }} · {{ config.speakers|length }} speaker{% if config.speakers|length != 1 %}s{% endif %}
</option>
{% endfor %}
</select>
<p class="hint">Presets are managed on the <a href="{{ url_for('web.speaker_configs_page') }}">Speakers page</a>.</p>
</label>
<div class="prepare-speaker-config__actions">
<button type="submit"
class="button button--ghost"
name="apply_speaker_config"
data-role="submit-speaker-analysis"
value="1"
formaction="{{ url_for('web.analyze_pending_job', pending_id=pending.id) }}"
formmethod="post"
formnovalidate
{% if not speaker_configs %}disabled{% endif %}>
Apply preset
</button>
<label class="toggle-pill">
<input type="checkbox" name="save_speaker_config" value="1">
<span>Save roster updates back to preset</span>
</label>
</div>
</div>
</section>
{% if pending.speakers %}
<div class="prepare-speakers">
<h2>Speaker settings</h2>
<p class="hint">Set pronunciations, lock specific voices, and audition sample paragraphs to hear casting choices.</p>
<ul class="speaker-list">
{% for speaker_id, speaker in pending.speakers.items() %}
{% set pronunciation_text = speaker.pronunciation or speaker.label %}
{% set selected_voice = speaker.resolved_voice or speaker.voice %}
{% set seen = namespace(values=[]) %}
{% set sample_quotes = speaker.sample_quotes or [] %}
{% set detected_gender = speaker.detected_gender or speaker.gender or 'unknown' %}
{% set current_gender = speaker.gender or detected_gender %}
{% set gender_label = 'Either' if current_gender == 'either' else (current_gender|title if current_gender != 'unknown' else 'Unknown') %}
{% set detected_label = 'Either' if detected_gender == 'either' else (detected_gender|title if detected_gender != 'unknown' else 'Unknown') %}
<li class="speaker-list__item"
data-speaker-id="{{ speaker_id }}"
data-default-pronunciation="{{ pronunciation_text }}">
<div class="speaker-line speaker-list__header">
<span class="speaker-list__name">{{ speaker.label }}</span>
<button type="button"
class="icon-button speaker-list__preview"
data-role="speaker-preview"
data-speaker-id="{{ speaker_id }}"
data-preview-source="pronunciation"
data-preview-text="{{ pronunciation_text|e }}"
data-language="{{ pending.language }}"
data-voice="{{ selected_voice or pending.voice }}"
data-speed="{{ '%.2f'|format(pending.speed) }}"
data-use-gpu="{{ 'true' if pending.use_gpu else 'false' }}"
aria-label="Preview pronunciation for {{ speaker.label }}"
title="Preview pronunciation">
🔊
</button>
</div>
<template data-role="speaker-samples">{{ sample_quotes | tojson }}</template>
<div class="speaker-list__meta">
<div class="speaker-gender" data-role="speaker-gender" data-speaker-id="{{ speaker_id }}">
<button type="button"
class="chip speaker-gender__pill"
data-role="gender-pill"
data-current="{{ current_gender }}">
{{ gender_label }} voice
</button>
<div class="speaker-gender__menu" data-role="gender-menu" hidden>
<p class="hint">Detected: {{ detected_label }}</p>
<div class="speaker-gender__options">
<button type="button" class="chip" data-role="gender-option" data-value="female">Female</button>
<button type="button" class="chip" data-role="gender-option" data-value="male">Male</button>
<button type="button" class="chip" data-role="gender-option" data-value="either">Either</button>
<button type="button" class="chip" data-role="gender-option" data-value="{{ detected_gender }}" {% if detected_gender == current_gender %}data-state="active"{% endif %}>
Use detected ({{ detected_label }})
</button>
</div>
</div>
<input type="hidden" name="speaker-{{ speaker_id }}-gender" value="{{ current_gender }}" data-role="gender-input">
<input type="hidden" name="speaker-{{ speaker_id }}-detected-gender" value="{{ detected_gender }}">
</div>
{% if speaker.get('analysis_count') %}
<span class="badge badge--muted">{{ speaker.analysis_count }} lines · {{ speaker.analysis_confidence|default('low')|title }} confidence</span>
{% endif %}
</div>
<label class="speaker-list__field" for="speaker-{{ speaker_id }}-pronunciation">
<span>Pronunciation</span>
<input type="text"
id="speaker-{{ speaker_id }}-pronunciation"
name="speaker-{{ speaker_id }}-pronunciation"
value="{{ pronunciation_text }}"
data-role="speaker-pronunciation"
placeholder="{{ speaker.label }}">
</label>
<div class="speaker-list__controls">
<div class="speaker-list__selection">
<label class="speaker-list__field" for="speaker-{{ speaker_id }}-voice">
<span>Assigned voice</span>
<select id="speaker-{{ speaker_id }}-voice"
name="speaker-{{ speaker_id }}-voice"
data-role="speaker-voice"
data-default-voice="{{ pending.voice }}">
<option value="" {% if not selected_voice %}selected{% endif %}>Use narrator voice ({{ pending.voice }})</option>
<option value="__custom_mix" data-role="custom-mix-option" {% if speaker.voice_formula %}selected{% else %}hidden disabled{% endif %}>
Custom mix
</option>
{% if speaker.recommended_voices %}
<optgroup label="Recommended">
{% for voice_id in speaker.recommended_voices[:6] %}
{% if voice_id not in seen.values %}
{% set voice_meta = options.voice_catalog_map.get(voice_id) or {} %}
<option value="{{ voice_id }}" {% if selected_voice == voice_id %}selected{% endif %}>
{{ voice_meta.display_name or voice_id }} · {{ voice_meta.language_label or voice_id[0]|upper }} · {{ voice_meta.gender or 'Unknown' }}
</option>
{% set _ = seen.values.append(voice_id) %}
{% endif %}
{% endfor %}
</optgroup>
{% endif %}
<optgroup label="All voices">
{% for voice in options.voice_catalog %}
{% if voice.id not in seen.values %}
<option value="{{ voice.id }}"
{% if selected_voice == voice.id %}selected{% endif %}>
{{ voice.display_name }} · {{ voice.language_label }} · {{ voice.gender }}
</option>
{% endif %}
{% endfor %}
</optgroup>
</select>
</label>
<button type="button"
class="button button--ghost button--small"
data-role="open-voice-browser"
data-speaker-id="{{ speaker_id }}">
Browse voices
</button>
<button type="button"
class="button button--ghost button--small"
data-role="generate-voice"
data-speaker-id="{{ speaker_id }}">
Generate voice
</button>
<button type="button"
class="button button--ghost button--small"
data-role="speaker-preview"
data-preview-kind="generated"
data-speaker-id="{{ speaker_id }}"
data-preview-source="generated"
data-preview-text="{{ pronunciation_text|e }}"
data-language="{{ pending.language }}"
data-voice="{{ speaker.voice_formula or selected_voice or pending.voice }}"
data-speed="{{ '%.2f'|format(pending.speed) }}"
data-use-gpu="{{ 'true' if pending.use_gpu else 'false' }}"