mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 05:40:26 +02:00
feat: Implement heteronym handling with extraction, UI integration, and processing logic
This commit is contained in:
@@ -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
|
||||||
@@ -906,6 +906,75 @@ def _compile_pronunciation_rules(
|
|||||||
return compiled
|
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(
|
def _apply_pronunciation_rules(
|
||||||
text: str,
|
text: str,
|
||||||
rules: List[Dict[str, Any]],
|
rules: List[Dict[str, Any]],
|
||||||
@@ -1306,6 +1375,14 @@ def run_conversion_job(job: Job) -> None:
|
|||||||
extraction = extract_from_path(job.stored_path)
|
extraction = extract_from_path(job.stored_path)
|
||||||
file_type = _infer_file_type(job.stored_path)
|
file_type = _infer_file_type(job.stored_path)
|
||||||
pronunciation_rules = _compile_pronunciation_rules(job.pronunciation_overrides)
|
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:
|
if pronunciation_rules:
|
||||||
count = len(pronunciation_rules)
|
count = len(pronunciation_rules)
|
||||||
job.add_log(
|
job.add_log(
|
||||||
@@ -1447,6 +1524,8 @@ def run_conversion_job(job: Job) -> None:
|
|||||||
) -> int:
|
) -> int:
|
||||||
nonlocal processed_chars, subtitle_index, current_time
|
nonlocal processed_chars, subtitle_index, current_time
|
||||||
source_text = str(text or "")
|
source_text = str(text or "")
|
||||||
|
if heteronym_sentence_rules:
|
||||||
|
source_text = _apply_heteronym_sentence_rules(source_text, heteronym_sentence_rules)
|
||||||
if pronunciation_rules:
|
if pronunciation_rules:
|
||||||
source_text = _apply_pronunciation_rules(
|
source_text = _apply_pronunciation_rules(
|
||||||
source_text,
|
source_text,
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ def list_manual_overrides(pending_id: str) -> ResponseReturnValue:
|
|||||||
return jsonify({
|
return jsonify({
|
||||||
"overrides": pending.manual_overrides or [],
|
"overrides": pending.manual_overrides or [],
|
||||||
"pronunciation_overrides": pending.pronunciation_overrides or [],
|
"pronunciation_overrides": pending.pronunciation_overrides or [],
|
||||||
|
"heteronym_overrides": getattr(pending, "heteronym_overrides", None) or [],
|
||||||
"language": pending.language or "en",
|
"language": pending.language or "en",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from abogen.pronunciation_store import (
|
|||||||
search_overrides as search_pronunciation_overrides,
|
search_overrides as search_pronunciation_overrides,
|
||||||
)
|
)
|
||||||
from abogen.web.routes.utils.settings import load_settings
|
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]]:
|
def collect_pronunciation_overrides(pending: PendingJob) -> List[Dict[str, Any]]:
|
||||||
language = pending.language or "en"
|
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:
|
def refresh_entity_summary(pending: PendingJob, chapters: Iterable[Mapping[str, Any]]) -> None:
|
||||||
settings = load_settings()
|
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"
|
language = pending.language or "en"
|
||||||
chapter_list: List[Mapping[str, Any]] = [chapter for chapter in chapters if isinstance(chapter, Mapping)]
|
chapter_list: List[Mapping[str, Any]] = [chapter for chapter in chapters if isinstance(chapter, Mapping)]
|
||||||
if not chapter_list:
|
if not chapter_list:
|
||||||
pending.entity_summary = {}
|
pending.entity_summary = {}
|
||||||
pending.entity_cache_key = ""
|
pending.entity_cache_key = ""
|
||||||
pending.pronunciation_overrides = pending.pronunciation_overrides or []
|
pending.pronunciation_overrides = pending.pronunciation_overrides or []
|
||||||
|
pending.heteronym_overrides = pending.heteronym_overrides or []
|
||||||
return
|
return
|
||||||
|
|
||||||
enabled_only = [chapter for chapter in chapter_list if chapter.get("enabled")]
|
enabled_only = [chapter for chapter in chapter_list if chapter.get("enabled")]
|
||||||
target_chapters = enabled_only or chapter_list
|
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)
|
result = extract_entities(target_chapters, language=language)
|
||||||
summary = dict(result.summary)
|
summary = dict(result.summary)
|
||||||
tokens: List[str] = []
|
tokens: List[str] = []
|
||||||
@@ -343,6 +356,7 @@ def pending_entities_payload(pending: PendingJob) -> Dict[str, Any]:
|
|||||||
"summary": pending.entity_summary or {},
|
"summary": pending.entity_summary or {},
|
||||||
"manual_overrides": pending.manual_overrides or [],
|
"manual_overrides": pending.manual_overrides or [],
|
||||||
"pronunciation_overrides": pending.pronunciation_overrides or [],
|
"pronunciation_overrides": pending.pronunciation_overrides or [],
|
||||||
|
"heteronym_overrides": getattr(pending, "heteronym_overrides", None) or [],
|
||||||
"cache_key": pending.entity_cache_key,
|
"cache_key": pending.entity_cache_key,
|
||||||
"language": pending.language or "en",
|
"language": pending.language or "en",
|
||||||
"recognition_enabled": recognition_enabled,
|
"recognition_enabled": recognition_enabled,
|
||||||
|
|||||||
@@ -396,6 +396,31 @@ def apply_prepare_form(
|
|||||||
|
|
||||||
enabled_overrides = [entry for entry in overrides if entry.get("enabled")]
|
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)
|
sync_pronunciation_overrides(pending)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ def submit_job(pending: PendingJob) -> str:
|
|||||||
entity_summary=getattr(pending, "entity_summary", None),
|
entity_summary=getattr(pending, "entity_summary", None),
|
||||||
manual_overrides=getattr(pending, "manual_overrides", None),
|
manual_overrides=getattr(pending, "manual_overrides", None),
|
||||||
pronunciation_overrides=getattr(pending, "pronunciation_overrides", None),
|
pronunciation_overrides=getattr(pending, "pronunciation_overrides", None),
|
||||||
|
heteronym_overrides=getattr(pending, "heteronym_overrides", None),
|
||||||
normalization_overrides=pending.normalization_overrides,
|
normalization_overrides=pending.normalization_overrides,
|
||||||
)
|
)
|
||||||
return job.id
|
return job.id
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ class Job:
|
|||||||
entity_summary: Dict[str, Any] = field(default_factory=dict)
|
entity_summary: Dict[str, Any] = field(default_factory=dict)
|
||||||
manual_overrides: List[Dict[str, Any]] = field(default_factory=list)
|
manual_overrides: List[Dict[str, Any]] = field(default_factory=list)
|
||||||
pronunciation_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)
|
normalization_overrides: Dict[str, Any] = field(default_factory=dict)
|
||||||
speaker_voice_languages: List[str] = field(default_factory=list)
|
speaker_voice_languages: List[str] = field(default_factory=list)
|
||||||
applied_speaker_config: Optional[str] = None
|
applied_speaker_config: Optional[str] = None
|
||||||
@@ -242,6 +243,7 @@ class Job:
|
|||||||
"entity_summary": dict(self.entity_summary),
|
"entity_summary": dict(self.entity_summary),
|
||||||
"manual_overrides": [dict(entry) for entry in self.manual_overrides],
|
"manual_overrides": [dict(entry) for entry in self.manual_overrides],
|
||||||
"pronunciation_overrides": [dict(entry) for entry in self.pronunciation_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),
|
"normalization_overrides": dict(self.normalization_overrides),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -565,6 +567,7 @@ class PendingJob:
|
|||||||
entity_summary: Dict[str, Any] = field(default_factory=dict)
|
entity_summary: Dict[str, Any] = field(default_factory=dict)
|
||||||
manual_overrides: List[Dict[str, Any]] = field(default_factory=list)
|
manual_overrides: List[Dict[str, Any]] = field(default_factory=list)
|
||||||
pronunciation_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
|
entity_cache_key: Optional[str] = None
|
||||||
wizard_max_step_index: int = 0
|
wizard_max_step_index: int = 0
|
||||||
|
|
||||||
@@ -646,6 +649,7 @@ class ConversionService:
|
|||||||
entity_summary: Optional[Mapping[str, Any]] = None,
|
entity_summary: Optional[Mapping[str, Any]] = None,
|
||||||
manual_overrides: Optional[Iterable[Mapping[str, Any]]] = None,
|
manual_overrides: Optional[Iterable[Mapping[str, Any]]] = None,
|
||||||
pronunciation_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,
|
normalization_overrides: Optional[Mapping[str, Any]] = None,
|
||||||
) -> Job:
|
) -> Job:
|
||||||
job_id = uuid.uuid4().hex
|
job_id = uuid.uuid4().hex
|
||||||
@@ -697,6 +701,7 @@ class ConversionService:
|
|||||||
entity_summary=dict(entity_summary or {}),
|
entity_summary=dict(entity_summary or {}),
|
||||||
manual_overrides=[dict(entry) for entry in manual_overrides] if manual_overrides else [],
|
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 [],
|
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 {}),
|
normalization_overrides=dict(normalization_overrides or {}),
|
||||||
)
|
)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
@@ -1184,6 +1189,7 @@ class ConversionService:
|
|||||||
"entity_summary": dict(job.entity_summary),
|
"entity_summary": dict(job.entity_summary),
|
||||||
"manual_overrides": [dict(entry) for entry in job.manual_overrides],
|
"manual_overrides": [dict(entry) for entry in job.manual_overrides],
|
||||||
"pronunciation_overrides": [dict(entry) for entry in job.pronunciation_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),
|
"normalization_overrides": dict(job.normalization_overrides),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1310,6 +1316,9 @@ class ConversionService:
|
|||||||
job.pronunciation_overrides = [
|
job.pronunciation_overrides = [
|
||||||
dict(entry) for entry in payload.get("pronunciation_overrides", []) if isinstance(entry, Mapping)
|
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.normalization_overrides = dict(payload.get("normalization_overrides", {}) or {})
|
||||||
job.pause_event.set()
|
job.pause_event.set()
|
||||||
return job
|
return job
|
||||||
|
|||||||
@@ -1107,6 +1107,7 @@ const initPrepare = (root = document) => {
|
|||||||
const entityCacheKeyData = parseJSONScript("entity-cache-key");
|
const entityCacheKeyData = parseJSONScript("entity-cache-key");
|
||||||
const manualOverridesSeed = parseJSONScript("manual-overrides-data") || [];
|
const manualOverridesSeed = parseJSONScript("manual-overrides-data") || [];
|
||||||
const pronunciationOverridesSeed = parseJSONScript("pronunciation-overrides-data") || [];
|
const pronunciationOverridesSeed = parseJSONScript("pronunciation-overrides-data") || [];
|
||||||
|
const heteronymOverridesSeed = parseJSONScript("heteronym-overrides-data") || [];
|
||||||
|
|
||||||
const entityTabs = form.querySelector('[data-role="entity-tabs"]');
|
const entityTabs = form.querySelector('[data-role="entity-tabs"]');
|
||||||
const entitiesUrl = form.dataset.entitiesUrl || "";
|
const entitiesUrl = form.dataset.entitiesUrl || "";
|
||||||
@@ -1124,6 +1125,7 @@ const initPrepare = (root = document) => {
|
|||||||
cacheKey: typeof entityCacheKeyData === "string" ? entityCacheKeyData : "",
|
cacheKey: typeof entityCacheKeyData === "string" ? entityCacheKeyData : "",
|
||||||
manualOverrides: Array.isArray(manualOverridesSeed) ? [...manualOverridesSeed] : [],
|
manualOverrides: Array.isArray(manualOverridesSeed) ? [...manualOverridesSeed] : [],
|
||||||
pronunciationOverrides: Array.isArray(pronunciationOverridesSeed) ? [...pronunciationOverridesSeed] : [],
|
pronunciationOverrides: Array.isArray(pronunciationOverridesSeed) ? [...pronunciationOverridesSeed] : [],
|
||||||
|
heteronymOverrides: Array.isArray(heteronymOverridesSeed) ? [...heteronymOverridesSeed] : [],
|
||||||
filters: {
|
filters: {
|
||||||
people: 0,
|
people: 0,
|
||||||
entities: 0,
|
entities: 0,
|
||||||
@@ -1171,6 +1173,10 @@ const initPrepare = (root = document) => {
|
|||||||
const manualOverridesEmpty = manualOverridesRoot?.querySelector('[data-role="manual-overrides-empty"]');
|
const manualOverridesEmpty = manualOverridesRoot?.querySelector('[data-role="manual-overrides-empty"]');
|
||||||
const manualOverrideSaveButton = manualOverridesRoot?.querySelector('[data-role="manual-override-save-all"]');
|
const manualOverrideSaveButton = manualOverridesRoot?.querySelector('[data-role="manual-override-save-all"]');
|
||||||
const manualOverrideStatusNode = manualOverridesRoot?.querySelector('[data-role="manual-override-status"]');
|
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 manualOverrideStatusTimer = null;
|
||||||
let manualOverrideStatusNonce = 0;
|
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 = {}) {
|
function applyEntityPayload(payload, options = {}) {
|
||||||
if (payload && typeof payload === "object") {
|
if (payload && typeof payload === "object") {
|
||||||
if (payload.summary) {
|
if (payload.summary) {
|
||||||
@@ -1890,6 +2055,9 @@ const initPrepare = (root = document) => {
|
|||||||
if (Array.isArray(payload.pronunciation_overrides)) {
|
if (Array.isArray(payload.pronunciation_overrides)) {
|
||||||
entityState.pronunciationOverrides = 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") {
|
if (typeof payload.cache_key === "string") {
|
||||||
entityState.cacheKey = payload.cache_key;
|
entityState.cacheKey = payload.cache_key;
|
||||||
}
|
}
|
||||||
@@ -1907,6 +2075,7 @@ const initPrepare = (root = document) => {
|
|||||||
}
|
}
|
||||||
renderEntitySummary();
|
renderEntitySummary();
|
||||||
renderManualOverrides();
|
renderManualOverrides();
|
||||||
|
renderHeteronymOverrides();
|
||||||
}
|
}
|
||||||
|
|
||||||
const filterVoicesByGender = (voices, genderHint) => {
|
const filterVoicesByGender = (voices, genderHint) => {
|
||||||
@@ -2517,6 +2686,7 @@ const initPrepare = (root = document) => {
|
|||||||
activateEntityTab(initialTab?.dataset.panel || "people");
|
activateEntityTab(initialTab?.dataset.panel || "people");
|
||||||
renderEntitySummary();
|
renderEntitySummary();
|
||||||
renderManualOverrides();
|
renderManualOverrides();
|
||||||
|
renderHeteronymOverrides();
|
||||||
triggerEntitiesRefresh();
|
triggerEntitiesRefresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -526,6 +526,28 @@
|
|||||||
</article>
|
</article>
|
||||||
</template>
|
</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>
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -567,6 +589,7 @@
|
|||||||
<script id="entity-cache-key" type="application/json">{{ pending.entity_cache_key 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="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="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-catalog-data" type="application/json">{{ options.voice_catalog | tojson }}</script>
|
||||||
<script id="voice-language-map" type="application/json">{{ options.languages | tojson }}</script>
|
<script id="voice-language-map" type="application/json">{{ options.languages | tojson }}</script>
|
||||||
{% if embed_scripts %}
|
{% if embed_scripts %}
|
||||||
|
|||||||
Reference in New Issue
Block a user