feat: Implement heteronym handling with extraction, UI integration, and processing logic

This commit is contained in:
JB
2025-12-15 06:41:13 -08:00
parent ef2b045b69
commit daf4d78766
9 changed files with 615 additions and 6 deletions
+287
View File
@@ -0,0 +1,287 @@
from __future__ import annotations
import hashlib
import re
from dataclasses import dataclass
from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple
import spacy
@dataclass(frozen=True)
class HeteronymVariant:
key: str
label: str
replacement_token: str
example_sentence: str
@dataclass(frozen=True)
class HeteronymSpec:
token: str
variants: Tuple[HeteronymVariant, HeteronymVariant]
def default_choice_for_token(self, spacy_token: Any) -> str:
"""Return the most likely variant key for this token."""
pos = (getattr(spacy_token, "pos_", "") or "").upper()
tag = (getattr(spacy_token, "tag_", "") or "").upper()
token_lower = self.token.casefold()
if token_lower == "wind":
# VERB => /waɪnd/, NOUN => /wɪnd/
return "verb" if pos == "VERB" else "noun"
if token_lower == "read":
# VBD/VBN => /rɛd/
return "past" if tag in {"VBD", "VBN"} else "present"
if token_lower == "tear":
return "verb" if pos == "VERB" else "noun"
if token_lower == "close":
return "verb" if pos == "VERB" else "adj"
if token_lower == "lead":
# Default to verb unless POS suggests noun.
return "metal" if pos == "NOUN" else "verb"
return self.variants[0].key
# Minimal, high-confidence starter set.
# NOTE: These replacements intentionally prioritize speech output.
# Some replacements may not be appropriate for subtitles/text exports.
_HETERONYM_SPECS: Dict[str, HeteronymSpec] = {
"wind": HeteronymSpec(
token="wind",
variants=(
HeteronymVariant(
key="noun",
label="Noun (the wind)",
replacement_token="wind",
example_sentence="Listen to the wind.",
),
HeteronymVariant(
key="verb",
label="Verb (to wind)",
replacement_token="wynd",
example_sentence="I need to wind the watch.",
),
),
),
"read": HeteronymSpec(
token="read",
variants=(
HeteronymVariant(
key="present",
label="Present (I read every day)",
replacement_token="read",
example_sentence="I read every day.",
),
HeteronymVariant(
key="past",
label="Past (I read it yesterday)",
replacement_token="red",
example_sentence="I read it yesterday.",
),
),
),
"tear": HeteronymSpec(
token="tear",
variants=(
HeteronymVariant(
key="noun",
label="Noun (a tear /crying/)",
replacement_token="tier",
example_sentence="A tear rolled down her cheek.",
),
HeteronymVariant(
key="verb",
label="Verb (to tear /rip/)",
replacement_token="tear",
example_sentence="Please don't tear the page.",
),
),
),
"close": HeteronymSpec(
token="close",
variants=(
HeteronymVariant(
key="adj",
label="Adjective (close /near/)",
replacement_token="close",
example_sentence="We are close to the station.",
),
HeteronymVariant(
key="verb",
label="Verb (close /klohz/)",
replacement_token="cloze",
example_sentence="Please close the door.",
),
),
),
"lead": HeteronymSpec(
token="lead",
variants=(
HeteronymVariant(
key="verb",
label="Verb (to lead)",
replacement_token="lead",
example_sentence="They will lead the way.",
),
HeteronymVariant(
key="metal",
label="Noun (lead /metal/)",
replacement_token="led",
example_sentence="The pipe was made of lead.",
),
),
),
}
def _hash_id(*parts: str) -> str:
digest = hashlib.sha1("\n".join(parts).encode("utf-8")).hexdigest()
return digest[:12]
_WORD_BOUNDARY_CACHE: Dict[str, re.Pattern[str]] = {}
def _word_boundary_pattern(token: str) -> re.Pattern[str]:
key = token.casefold()
cached = _WORD_BOUNDARY_CACHE.get(key)
if cached is not None:
return cached
escaped = re.escape(token)
pattern = re.compile(rf"(?i)(?<!\w){escaped}(?P<possessive>'s|\u2019s|\u2019)?(?!\w)")
_WORD_BOUNDARY_CACHE[key] = pattern
return pattern
def _preserve_case(replacement: str, original: str) -> str:
if not replacement:
return replacement
if original.isupper():
return replacement.upper()
if original[:1].isupper():
return replacement[:1].upper() + replacement[1:]
return replacement
def _build_replacement_sentence(sentence: str, token: str, replacement_token: str) -> str:
pattern = _word_boundary_pattern(token)
def _repl(match: re.Match[str]) -> str:
matched = match.group(0) or ""
suffix = match.group("possessive") or ""
base = matched[: len(matched) - len(suffix)] if suffix else matched
return _preserve_case(replacement_token, base) + suffix
return pattern.sub(_repl, sentence)
def _load_spacy(language: str) -> Any:
# English only for now.
# Use installed small model; keep it simple.
lang = (language or "en").lower()
if lang.startswith("en"):
try:
return spacy.load("en_core_web_sm")
except Exception:
return spacy.blank("en")
return spacy.blank("xx")
def extract_heteronym_overrides(
chapters: Sequence[Mapping[str, Any]],
*,
language: str,
existing: Optional[Iterable[Mapping[str, Any]]] = None,
) -> List[Dict[str, Any]]:
"""Extract distinct heteronym-containing sentences from chapters.
Returns entries shaped for persistence + UI.
Each entry contains:
- id
- token
- sentence
- options: [{key,label,replacement_token,replacement_sentence,example_sentence}]
- default_choice
- choice
"""
lang = (language or "en").lower()
if not lang.startswith("en"):
return []
nlp = _load_spacy(lang)
previous_choices: Dict[str, str] = {}
if existing:
for item in existing:
if not isinstance(item, Mapping):
continue
entry_id = str(item.get("id") or "").strip()
choice = str(item.get("choice") or "").strip()
if entry_id and choice:
previous_choices[entry_id] = choice
results: List[Dict[str, Any]] = []
seen: set[tuple[str, str]] = set()
for chapter in chapters:
if not isinstance(chapter, Mapping):
continue
text = str(chapter.get("text") or "")
if not text.strip():
continue
doc = nlp(text)
for sent in getattr(doc, "sents", []):
sentence = str(getattr(sent, "text", "") or "").strip()
if not sentence:
continue
for token in sent:
token_text = str(getattr(token, "text", "") or "")
if not token_text:
continue
token_key = token_text.casefold()
spec = _HETERONYM_SPECS.get(token_key)
if not spec:
continue
dedupe_key = (token_key, sentence)
if dedupe_key in seen:
continue
seen.add(dedupe_key)
entry_id = _hash_id(token_key, sentence)
default_choice = spec.default_choice_for_token(token)
choice = previous_choices.get(entry_id, default_choice)
options: List[Dict[str, Any]] = []
for variant in spec.variants:
replacement_sentence = _build_replacement_sentence(
sentence, token=spec.token, replacement_token=variant.replacement_token
)
options.append(
{
"key": variant.key,
"label": variant.label,
"replacement_token": variant.replacement_token,
"replacement_sentence": replacement_sentence,
"example_sentence": variant.example_sentence,
}
)
results.append(
{
"id": entry_id,
"token": token_text,
"token_lower": token_key,
"sentence": sentence,
"options": options,
"default_choice": default_choice,
"choice": choice,
}
)
return results
+79
View File
@@ -906,6 +906,75 @@ def _compile_pronunciation_rules(
return compiled
def _compile_heteronym_sentence_rules(
overrides: Optional[Iterable[Mapping[str, Any]]],
) -> List[Dict[str, Any]]:
"""Compile sentence-level replacements for heteronym disambiguation.
These are intentionally scoped to a specific sentence string rather than a token,
so we can apply different pronunciations for the same word in different contexts.
Expected override entry shape (from pending/job):
- sentence: original sentence text
- choice: selected option key
- options: [{key, replacement_sentence, ...}]
"""
if not overrides:
return []
compiled: List[Dict[str, Any]] = []
seen: set[str] = set()
for entry in overrides:
if not isinstance(entry, Mapping):
continue
sentence = str(entry.get("sentence") or "").strip()
if not sentence:
continue
choice = str(entry.get("choice") or "").strip()
if not choice:
continue
replacement_sentence = ""
options = entry.get("options")
if isinstance(options, list):
for opt in options:
if not isinstance(opt, Mapping):
continue
if str(opt.get("key") or "").strip() == choice:
replacement_sentence = str(opt.get("replacement_sentence") or "").strip()
break
if not replacement_sentence:
continue
rule_key = f"{sentence}\n{choice}".casefold()
if rule_key in seen:
continue
seen.add(rule_key)
parts = [p for p in re.split(r"\s+", sentence) if p]
if not parts:
continue
pattern_text = r"\s+".join(re.escape(p) for p in parts)
pattern = re.compile(pattern_text)
compiled.append({"pattern": pattern, "replacement": replacement_sentence})
# Replace longer sentences first to avoid partial matches.
compiled.sort(key=lambda item: len(item["pattern"].pattern), reverse=True)
return compiled
def _apply_heteronym_sentence_rules(text: str, rules: List[Dict[str, Any]]) -> str:
if not text or not rules:
return text
result = text
for rule in rules:
pattern = rule["pattern"]
replacement = rule["replacement"]
result = pattern.sub(replacement, result)
return result
def _apply_pronunciation_rules(
text: str,
rules: List[Dict[str, Any]],
@@ -1306,6 +1375,14 @@ def run_conversion_job(job: Job) -> None:
extraction = extract_from_path(job.stored_path)
file_type = _infer_file_type(job.stored_path)
pronunciation_rules = _compile_pronunciation_rules(job.pronunciation_overrides)
heteronym_sentence_rules = _compile_heteronym_sentence_rules(
getattr(job, "heteronym_overrides", None)
)
if heteronym_sentence_rules:
job.add_log(
f"Applying {len(heteronym_sentence_rules)} heteronym override{'s' if len(heteronym_sentence_rules) != 1 else ''} during conversion.",
level="debug",
)
if pronunciation_rules:
count = len(pronunciation_rules)
job.add_log(
@@ -1447,6 +1524,8 @@ def run_conversion_job(job: Job) -> None:
) -> int:
nonlocal processed_chars, subtitle_index, current_time
source_text = str(text or "")
if heteronym_sentence_rules:
source_text = _apply_heteronym_sentence_rules(source_text, heteronym_sentence_rules)
if pronunciation_rules:
source_text = _apply_pronunciation_rules(
source_text,
+1
View File
@@ -64,6 +64,7 @@ def list_manual_overrides(pending_id: str) -> ResponseReturnValue:
return jsonify({
"overrides": pending.manual_overrides or [],
"pronunciation_overrides": pending.pronunciation_overrides or [],
"heteronym_overrides": getattr(pending, "heteronym_overrides", None) or [],
"language": pending.language or "en",
})
+20 -6
View File
@@ -17,6 +17,7 @@ from abogen.pronunciation_store import (
search_overrides as search_pronunciation_overrides,
)
from abogen.web.routes.utils.settings import load_settings
from abogen.heteronym_overrides import extract_heteronym_overrides
def collect_pronunciation_overrides(pending: PendingJob) -> List[Dict[str, Any]]:
language = pending.language or "en"
@@ -140,22 +141,34 @@ def sync_pronunciation_overrides(pending: PendingJob) -> None:
def refresh_entity_summary(pending: PendingJob, chapters: Iterable[Mapping[str, Any]]) -> None:
settings = load_settings()
if not bool(settings.get("enable_entity_recognition", True)):
pending.entity_summary = {}
pending.entity_cache_key = ""
pending.pronunciation_overrides = pending.pronunciation_overrides or []
return
language = pending.language or "en"
chapter_list: List[Mapping[str, Any]] = [chapter for chapter in chapters if isinstance(chapter, Mapping)]
if not chapter_list:
pending.entity_summary = {}
pending.entity_cache_key = ""
pending.pronunciation_overrides = pending.pronunciation_overrides or []
pending.heteronym_overrides = pending.heteronym_overrides or []
return
enabled_only = [chapter for chapter in chapter_list if chapter.get("enabled")]
target_chapters = enabled_only or chapter_list
# Always compute heteronym overrides (English only). Preserve any prior selections.
try:
pending.heteronym_overrides = extract_heteronym_overrides(
target_chapters,
language=language,
existing=getattr(pending, "heteronym_overrides", None),
)
except Exception:
pending.heteronym_overrides = getattr(pending, "heteronym_overrides", []) or []
if not bool(settings.get("enable_entity_recognition", True)):
pending.entity_summary = {}
pending.entity_cache_key = ""
pending.pronunciation_overrides = pending.pronunciation_overrides or []
return
result = extract_entities(target_chapters, language=language)
summary = dict(result.summary)
tokens: List[str] = []
@@ -343,6 +356,7 @@ def pending_entities_payload(pending: PendingJob) -> Dict[str, Any]:
"summary": pending.entity_summary or {},
"manual_overrides": pending.manual_overrides or [],
"pronunciation_overrides": pending.pronunciation_overrides or [],
"heteronym_overrides": getattr(pending, "heteronym_overrides", None) or [],
"cache_key": pending.entity_cache_key,
"language": pending.language or "en",
"recognition_enabled": recognition_enabled,
+25
View File
@@ -396,6 +396,31 @@ def apply_prepare_form(
enabled_overrides = [entry for entry in overrides if entry.get("enabled")]
heteronym_entries = getattr(pending, "heteronym_overrides", None)
if isinstance(heteronym_entries, list) and heteronym_entries:
for entry in heteronym_entries:
if not isinstance(entry, dict):
continue
entry_id = str(entry.get("entry_id") or entry.get("id") or "").strip()
if not entry_id:
continue
raw_choice = form.get(f"heteronym-{entry_id}-choice")
if raw_choice is None:
continue
choice = str(raw_choice).strip()
if not choice:
continue
options = entry.get("options")
if isinstance(options, list) and options:
allowed = {
str(opt.get("key")).strip()
for opt in options
if isinstance(opt, dict) and str(opt.get("key") or "").strip()
}
if allowed and choice not in allowed:
continue
entry["choice"] = choice
sync_pronunciation_overrides(pending)
return (
+1
View File
@@ -59,6 +59,7 @@ def submit_job(pending: PendingJob) -> str:
entity_summary=getattr(pending, "entity_summary", None),
manual_overrides=getattr(pending, "manual_overrides", None),
pronunciation_overrides=getattr(pending, "pronunciation_overrides", None),
heteronym_overrides=getattr(pending, "heteronym_overrides", None),
normalization_overrides=pending.normalization_overrides,
)
return job.id
+9
View File
@@ -152,6 +152,7 @@ class Job:
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)
heteronym_overrides: List[Dict[str, Any]] = field(default_factory=list)
normalization_overrides: Dict[str, Any] = field(default_factory=dict)
speaker_voice_languages: List[str] = field(default_factory=list)
applied_speaker_config: Optional[str] = None
@@ -242,6 +243,7 @@ class Job:
"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],
"heteronym_overrides": [dict(entry) for entry in self.heteronym_overrides],
"normalization_overrides": dict(self.normalization_overrides),
}
@@ -565,6 +567,7 @@ class PendingJob:
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)
heteronym_overrides: List[Dict[str, Any]] = field(default_factory=list)
entity_cache_key: Optional[str] = None
wizard_max_step_index: int = 0
@@ -646,6 +649,7 @@ class ConversionService:
entity_summary: Optional[Mapping[str, Any]] = None,
manual_overrides: Optional[Iterable[Mapping[str, Any]]] = None,
pronunciation_overrides: Optional[Iterable[Mapping[str, Any]]] = None,
heteronym_overrides: Optional[Iterable[Mapping[str, Any]]] = None,
normalization_overrides: Optional[Mapping[str, Any]] = None,
) -> Job:
job_id = uuid.uuid4().hex
@@ -697,6 +701,7 @@ class ConversionService:
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 [],
heteronym_overrides=[dict(entry) for entry in heteronym_overrides] if heteronym_overrides else [],
normalization_overrides=dict(normalization_overrides or {}),
)
with self._lock:
@@ -1184,6 +1189,7 @@ class ConversionService:
"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],
"heteronym_overrides": [dict(entry) for entry in job.heteronym_overrides],
"normalization_overrides": dict(job.normalization_overrides),
}
@@ -1310,6 +1316,9 @@ class ConversionService:
job.pronunciation_overrides = [
dict(entry) for entry in payload.get("pronunciation_overrides", []) if isinstance(entry, Mapping)
]
job.heteronym_overrides = [
dict(entry) for entry in payload.get("heteronym_overrides", []) if isinstance(entry, Mapping)
]
job.normalization_overrides = dict(payload.get("normalization_overrides", {}) or {})
job.pause_event.set()
return job
+170
View File
@@ -1107,6 +1107,7 @@ const initPrepare = (root = document) => {
const entityCacheKeyData = parseJSONScript("entity-cache-key");
const manualOverridesSeed = parseJSONScript("manual-overrides-data") || [];
const pronunciationOverridesSeed = parseJSONScript("pronunciation-overrides-data") || [];
const heteronymOverridesSeed = parseJSONScript("heteronym-overrides-data") || [];
const entityTabs = form.querySelector('[data-role="entity-tabs"]');
const entitiesUrl = form.dataset.entitiesUrl || "";
@@ -1124,6 +1125,7 @@ const initPrepare = (root = document) => {
cacheKey: typeof entityCacheKeyData === "string" ? entityCacheKeyData : "",
manualOverrides: Array.isArray(manualOverridesSeed) ? [...manualOverridesSeed] : [],
pronunciationOverrides: Array.isArray(pronunciationOverridesSeed) ? [...pronunciationOverridesSeed] : [],
heteronymOverrides: Array.isArray(heteronymOverridesSeed) ? [...heteronymOverridesSeed] : [],
filters: {
people: 0,
entities: 0,
@@ -1171,6 +1173,10 @@ const initPrepare = (root = document) => {
const manualOverridesEmpty = manualOverridesRoot?.querySelector('[data-role="manual-overrides-empty"]');
const manualOverrideSaveButton = manualOverridesRoot?.querySelector('[data-role="manual-override-save-all"]');
const manualOverrideStatusNode = manualOverridesRoot?.querySelector('[data-role="manual-override-status"]');
const heteronymOverridesRoot = manualOverridesRoot?.querySelector('[data-role="heteronym-overrides"]');
const heteronymOverrideList = heteronymOverridesRoot?.querySelector('[data-role="heteronym-override-list"]');
const heteronymOverrideTemplate = heteronymOverridesRoot?.querySelector('template[data-role="heteronym-override-template"]');
const heteronymOverridesEmpty = heteronymOverridesRoot?.querySelector('[data-role="heteronym-overrides-empty"]');
let manualOverrideStatusTimer = null;
let manualOverrideStatusNonce = 0;
@@ -1879,6 +1885,165 @@ const initPrepare = (root = document) => {
});
}
const escapeRegExp = (value) => String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const resolveHeteronymTooltip = (entry) => {
if (!entry || typeof entry !== "object") return "";
const options = Array.isArray(entry.options) ? entry.options : [];
if (options.length < 2) return "";
const parts = [];
options.slice(0, 2).forEach((opt) => {
const label = String(opt?.label || "").trim();
const example = String(opt?.example_sentence || "").trim();
if (label && example) {
parts.push(`${label}: ${example}`);
} else if (example) {
parts.push(example);
}
});
return parts.join("\n");
};
const fillHighlightedSentence = (container, sentence, token, tooltip) => {
if (!container) return;
container.textContent = "";
const rawSentence = String(sentence || "");
const rawToken = String(token || "");
if (!rawSentence) {
return;
}
if (!rawToken) {
container.textContent = rawSentence;
return;
}
const pattern = new RegExp(`\\b${escapeRegExp(rawToken)}\\b`, "i");
const match = pattern.exec(rawSentence);
if (!match) {
container.textContent = rawSentence;
return;
}
const before = rawSentence.slice(0, match.index);
const hit = rawSentence.slice(match.index, match.index + match[0].length);
const after = rawSentence.slice(match.index + match[0].length);
if (before) {
container.appendChild(document.createTextNode(before));
}
const chip = document.createElement("span");
chip.className = "chip";
chip.textContent = hit;
if (tooltip) {
chip.title = tooltip;
}
container.appendChild(chip);
if (after) {
container.appendChild(document.createTextNode(after));
}
};
function renderHeteronymOverrides() {
if (!heteronymOverrideList) return;
heteronymOverrideList.innerHTML = "";
const entries = Array.isArray(entityState.heteronymOverrides) ? entityState.heteronymOverrides : [];
if (!entries.length) {
if (heteronymOverridesEmpty) {
heteronymOverridesEmpty.hidden = false;
}
return;
}
if (heteronymOverridesEmpty) {
heteronymOverridesEmpty.hidden = true;
}
entries.forEach((entry) => {
if (!entry || typeof entry !== "object") return;
const entryId = String(entry.entry_id || entry.id || "").trim();
if (!entryId) return;
const sentence = String(entry.sentence || "").trim();
if (!sentence) return;
const token = String(entry.token || "").trim();
const node = cloneTemplate(heteronymOverrideTemplate);
if (!node) return;
node.dataset.entryId = entryId;
const sentenceEl = node.querySelector('[data-role="heteronym-sentence"]');
if (sentenceEl) {
const tooltip = resolveHeteronymTooltip(entry);
fillHighlightedSentence(sentenceEl, sentence, token, tooltip);
}
const notesEl = node.querySelector('[data-role="heteronym-notes"]');
if (notesEl) {
const suggested = String(entry.default_choice || "").trim();
if (suggested) {
const options = Array.isArray(entry.options) ? entry.options : [];
const match = options.find((opt) => String(opt?.key || "").trim() === suggested);
const label = String(match?.label || "").trim();
notesEl.textContent = label ? `Suggested: ${label}` : "";
notesEl.hidden = !notesEl.textContent;
} else {
notesEl.hidden = true;
}
}
const optionsContainer = node.querySelector('[data-role="heteronym-options"]');
if (optionsContainer) {
optionsContainer.innerHTML = "";
const options = Array.isArray(entry.options) ? entry.options : [];
const selectedKey = String(entry.choice || entry.default_choice || "").trim();
options.slice(0, 2).forEach((opt, index) => {
if (!opt || typeof opt !== "object") return;
const key = String(opt.key || "").trim();
const label = String(opt.label || "Option").trim();
const previewSentence = String(opt.replacement_sentence || sentence).trim();
if (!key) return;
const wrapper = document.createElement("div");
wrapper.className = "entity-inline-override__actions";
const choiceLabel = document.createElement("label");
choiceLabel.className = "toggle-pill";
const input = document.createElement("input");
input.type = "radio";
input.name = `heteronym-${entryId}-choice`;
input.value = key;
if (selectedKey) {
input.checked = selectedKey === key;
} else {
input.checked = index === 0;
}
const span = document.createElement("span");
span.textContent = label;
choiceLabel.appendChild(input);
choiceLabel.appendChild(span);
const previewButton = document.createElement("button");
previewButton.type = "button";
previewButton.className = "button button--ghost button--small";
previewButton.dataset.role = "speaker-preview";
previewButton.dataset.previewText = previewSentence;
previewButton.dataset.voice = baseVoice || "";
previewButton.dataset.language = languageCode;
previewButton.dataset.speed = defaultSpeed;
previewButton.dataset.useGpu = useGpuDefault;
previewButton.textContent = "Preview";
previewButton.disabled = !previewButton.dataset.voice;
previewButton.setAttribute("aria-disabled", previewButton.disabled ? "true" : "false");
wrapper.appendChild(choiceLabel);
wrapper.appendChild(previewButton);
optionsContainer.appendChild(wrapper);
});
}
heteronymOverrideList.appendChild(node);
});
}
function applyEntityPayload(payload, options = {}) {
if (payload && typeof payload === "object") {
if (payload.summary) {
@@ -1890,6 +2055,9 @@ const initPrepare = (root = document) => {
if (Array.isArray(payload.pronunciation_overrides)) {
entityState.pronunciationOverrides = payload.pronunciation_overrides;
}
if (Array.isArray(payload.heteronym_overrides)) {
entityState.heteronymOverrides = payload.heteronym_overrides;
}
if (typeof payload.cache_key === "string") {
entityState.cacheKey = payload.cache_key;
}
@@ -1907,6 +2075,7 @@ const initPrepare = (root = document) => {
}
renderEntitySummary();
renderManualOverrides();
renderHeteronymOverrides();
}
const filterVoicesByGender = (voices, genderHint) => {
@@ -2517,6 +2686,7 @@ const initPrepare = (root = document) => {
activateEntityTab(initialTab?.dataset.panel || "people");
renderEntitySummary();
renderManualOverrides();
renderHeteronymOverrides();
triggerEntitiesRefresh();
}
@@ -526,6 +526,28 @@
</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>
<section class="manual-overrides__section" data-role="heteronym-overrides">
<header class="manual-overrides__header">
<div class="manual-overrides__copy">
<h2>Heteronyms</h2>
<p class="hint">Review sentences that contain a word with multiple pronunciations. The suggested option is selected by default. Hover the highlighted word for examples and use Preview to audition each pronunciation.</p>
</div>
</header>
<div class="manual-overrides__list" data-role="heteronym-override-list"></div>
<template data-role="heteronym-override-template">
<article class="manual-override" data-entry-id="">
<header class="manual-override__header">
<div>
<h3 class="manual-override__label" data-role="heteronym-sentence"></h3>
<p class="manual-override__notes" data-role="heteronym-notes"></p>
</div>
</header>
<div class="manual-override__body" data-role="heteronym-options"></div>
</article>
</template>
<p class="manual-overrides__empty" data-role="heteronym-overrides-empty" hidden>No heteronyms found in the current selection.</p>
</section>
</div>
</section>
</div>
@@ -567,6 +589,7 @@
<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 id="heteronym-overrides-data" type="application/json">{{ pending.heteronym_overrides or [] | 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>
{% if embed_scripts %}