diff --git a/abogen/heteronym_overrides.py b/abogen/heteronym_overrides.py new file mode 100644 index 0000000..4c6e44a --- /dev/null +++ b/abogen/heteronym_overrides.py @@ -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)(?'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 diff --git a/abogen/web/conversion_runner.py b/abogen/web/conversion_runner.py index ca20d4d..939686e 100644 --- a/abogen/web/conversion_runner.py +++ b/abogen/web/conversion_runner.py @@ -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, diff --git a/abogen/web/routes/entities.py b/abogen/web/routes/entities.py index 5daadf3..f1c7242 100644 --- a/abogen/web/routes/entities.py +++ b/abogen/web/routes/entities.py @@ -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", }) diff --git a/abogen/web/routes/utils/entity.py b/abogen/web/routes/utils/entity.py index 7b81373..9af88ef 100644 --- a/abogen/web/routes/utils/entity.py +++ b/abogen/web/routes/utils/entity.py @@ -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, diff --git a/abogen/web/routes/utils/form.py b/abogen/web/routes/utils/form.py index c8cb724..63ca313 100644 --- a/abogen/web/routes/utils/form.py +++ b/abogen/web/routes/utils/form.py @@ -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 ( diff --git a/abogen/web/routes/utils/service.py b/abogen/web/routes/utils/service.py index ecf6277..c138fcb 100644 --- a/abogen/web/routes/utils/service.py +++ b/abogen/web/routes/utils/service.py @@ -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 diff --git a/abogen/web/service.py b/abogen/web/service.py index 3b47995..0c549b8 100644 --- a/abogen/web/service.py +++ b/abogen/web/service.py @@ -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 diff --git a/abogen/web/static/prepare.js b/abogen/web/static/prepare.js index 2de22a0..e01efc5 100644 --- a/abogen/web/static/prepare.js +++ b/abogen/web/static/prepare.js @@ -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(); } diff --git a/abogen/web/templates/partials/new_job_step_entities.html b/abogen/web/templates/partials/new_job_step_entities.html index a4c10b0..9e38e75 100644 --- a/abogen/web/templates/partials/new_job_step_entities.html +++ b/abogen/web/templates/partials/new_job_step_entities.html @@ -526,6 +526,28 @@
No overrides yet. Use search or add a custom entry to begin.
+ +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.
+No heteronyms found in the current selection.
+