Two groups of changes, committed together as the follow-up to the initial push: 1. .gitmodules — submodule Qwen3-TTS now points at the internal Gitea fork (ssh://git@mini-eq:2222/umbragit/Qwen3-TTS.git) instead of upstream GitHub, so a clone actually gets the UmbraAudio branding/integration commit 012e452 rather than vanilla upstream. 2. New infra/ tooling for the cross-machine work in spec 21: agent_bridge, remote_credential_broker (Keychain-backed, secrets read at runtime and generated via read_secret/random_secret — no hardcoded credentials), the enterprise bootstrap scripts, and the oldbox SSH/auth prompts. Plus backup/ restore scripts, two specs (18 UmbraTrans, 19 enterprise control plane), umbraaudio/voices/lmstudio_gate.py, and in-flight edits to the discovery/attribution/profiling modules, config, chapter_builder, emotion, pronunciation, qwen_tts and routes. Also adds *.db to .gitignore: SQLite project databases are runtime state and no .db file was tracked before. Verified before staging: .env's only change is UMBRA_TTS__QWEN_TTS_HELPER_URL and the committed copy has zero secret-looking values; the broker ships only config.example.json.
829 lines
26 KiB
Python
829 lines
26 KiB
Python
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
import re
|
||
import urllib.error
|
||
import urllib.parse
|
||
import urllib.request
|
||
from typing import TYPE_CHECKING, Any
|
||
|
||
if TYPE_CHECKING:
|
||
from pathlib import Path
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
PHONETIC_NAMES_FILE = "phonetic_names.json"
|
||
PRONUNCIATION_AUDIO_DIR = "pronunciation_audio"
|
||
PRONUNCIATION_STATUS_FILE = "pronunciation_status.json"
|
||
|
||
IPAS = "https://en.wiktionary.org/w/api.php"
|
||
DICT_API = "https://api.dictionaryapi.dev/api/v2/entries/en/{word}"
|
||
WIKI_API = "https://en.wikipedia.org/w/api.php"
|
||
BEHIND_THE_NAME = "https://www.behindthename.com/name/{word}"
|
||
|
||
_USER_AGENT = "UmbraAudio/1.0 (audiobook pronunciation lookup; proper-name phonetic review)"
|
||
|
||
# Longest sequences first so digraphs win over single letters.
|
||
_IPA_TO_SIMPLE: list[tuple[str, str]] = [
|
||
("ɑː", "AH"),
|
||
("ɔː", "AW"),
|
||
("eɪ", "AY"),
|
||
("aɪ", "EYE"),
|
||
("ɔɪ", "OY"),
|
||
("aʊ", "OW"),
|
||
("oʊ", "OH"),
|
||
("ɪə", "EER"),
|
||
("ɛə", "AIR"),
|
||
("ʊə", "OOR"),
|
||
("iː", "EE"),
|
||
("uː", "OO"),
|
||
("ɜː", "ER"),
|
||
("ɚ", "ER"),
|
||
("ɹ̩", "ER"),
|
||
("dʒ", "J"),
|
||
("tʃ", "CH"),
|
||
("ʃ", "SH"),
|
||
("ʒ", "ZH"),
|
||
("ð", "TH"),
|
||
("θ", "TH"),
|
||
("ŋ", "NG"),
|
||
("ɹ", "R"),
|
||
("ɝ", "ER"),
|
||
("ɪ", "IH"),
|
||
("ɛ", "EH"),
|
||
("æ", "AE"),
|
||
("ʌ", "UH"),
|
||
("ʊ", "UH"),
|
||
("ə", "UH"),
|
||
("ɑ", "AH"),
|
||
("ɔ", "AW"),
|
||
("ɒ", "AH"),
|
||
("j", "Y"),
|
||
("w", "W"),
|
||
("ɡ", "G"),
|
||
("ʍ", "W"),
|
||
("ʔ", "'"),
|
||
("ɫ", "L"),
|
||
("n̩", "N"),
|
||
("m̩", "M"),
|
||
("l̩", "L"),
|
||
("b", "B"),
|
||
("d", "D"),
|
||
("f", "F"),
|
||
("g", "G"),
|
||
("h", "H"),
|
||
("k", "K"),
|
||
("l", "L"),
|
||
("m", "M"),
|
||
("n", "N"),
|
||
("p", "P"),
|
||
("r", "R"),
|
||
("s", "S"),
|
||
("t", "T"),
|
||
("v", "V"),
|
||
("z", "Z"),
|
||
]
|
||
|
||
|
||
def _legacy_record(spelling: str) -> dict[str, Any]:
|
||
"""Old schema stored name -> plain spelling; treat those as unconfirmed legacy data."""
|
||
return {
|
||
"phonetic": spelling,
|
||
"source": "legacy",
|
||
"confirmed": False,
|
||
"common": False,
|
||
}
|
||
|
||
|
||
def load_records(data_dir: Path) -> dict[str, dict[str, Any]]:
|
||
"""Load rich pronunciation records, migrating the legacy name->spelling schema."""
|
||
path = data_dir / PHONETIC_NAMES_FILE
|
||
if not path.exists():
|
||
return {}
|
||
try:
|
||
with open(path) as f:
|
||
raw = json.load(f)
|
||
except (json.JSONDecodeError, OSError) as e:
|
||
logger.warning("Failed to read %s: %s", path, e)
|
||
return {}
|
||
if not isinstance(raw, dict):
|
||
return {}
|
||
records: dict[str, dict[str, Any]] = {}
|
||
for name, value in raw.items():
|
||
if isinstance(value, dict):
|
||
rec = dict(value)
|
||
rec.setdefault("phonetic", "")
|
||
rec.setdefault("source", "")
|
||
rec.setdefault("confirmed", False)
|
||
rec.setdefault("common", False)
|
||
records[name] = rec
|
||
elif isinstance(value, str) and value:
|
||
records[name] = _legacy_record(value)
|
||
return records
|
||
|
||
|
||
def save_records(data_dir: Path, records: dict[str, dict[str, Any]]) -> None:
|
||
path = data_dir / PHONETIC_NAMES_FILE
|
||
data_dir.mkdir(parents=True, exist_ok=True)
|
||
with open(path, "w") as f:
|
||
json.dump(records, f, indent=2)
|
||
|
||
|
||
def load_phonetic_names(
|
||
data_dir: Path,
|
||
*,
|
||
include_common: bool = False,
|
||
) -> dict[str, str]:
|
||
"""Confirmed spellings only — the map actually applied to utterance text.
|
||
|
||
Auto-confirmed common-word lookups (source ``dictionaryapi``) are excluded
|
||
by default: neural TTS already reads common English words naturally, and
|
||
feeding aggressive respellings (e.g. ``Author -> AWTHUH``) garbles the
|
||
audio. User-set records (source ``user``) and proper-name records (e.g.
|
||
``wiktionary``) are always applied; pass ``include_common=True`` to apply
|
||
common-word respellings too.
|
||
"""
|
||
return {
|
||
name: rec["phonetic"]
|
||
for name, rec in load_records(data_dir).items()
|
||
if rec.get("confirmed")
|
||
and rec.get("phonetic")
|
||
and (include_common or rec.get("source") != "dictionaryapi")
|
||
}
|
||
|
||
|
||
_CURLY_TO_ASCII = str.maketrans(
|
||
{
|
||
"\u2018": "'",
|
||
"\u2019": "'",
|
||
"\u201c": '"',
|
||
"\u201d": '"',
|
||
"\u2013": "-",
|
||
"\u2014": "-",
|
||
"\u2026": "...",
|
||
}
|
||
)
|
||
|
||
|
||
def normalize_tts_text(text: str) -> str:
|
||
"""Replace curly punctuation with ASCII equivalents before synthesis.
|
||
|
||
Qwen3-TTS's tokenizer has no tokens for curly quotes/apostrophes/dashes —
|
||
they fall through to rare byte tokens (``'Author’s'`` tokenizes as
|
||
``Author + âĢĻs``), which the model pronounces poorly (``Author's`` ->
|
||
``Authos``). Straight ASCII punctuation is common in training data and is
|
||
pronounced reliably.
|
||
"""
|
||
return text.translate(_CURLY_TO_ASCII)
|
||
|
||
|
||
def apply_phonetics(text: str, phonetic: dict[str, str]) -> str:
|
||
if not phonetic:
|
||
return normalize_tts_text(text)
|
||
text = normalize_tts_text(text)
|
||
for name, spelling in sorted(phonetic.items(), key=lambda x: -len(x[0])):
|
||
text = text.replace(name, spelling)
|
||
return text
|
||
|
||
|
||
# First-word tokens that are NOT proper names even when capitalized (role labels,
|
||
# titles, narrator variants) — an utterance starting with one of these must not
|
||
# gain a ", " pause.
|
||
_PAUSE_EXCLUDED_TOKENS = frozenset({
|
||
"a", "an", "and", "apprentice", "at", "blacksmith", "border", "boy",
|
||
"captain", "child", "children", "dr", "elder", "from", "general", "girl",
|
||
"guard", "guards", "handler", "he", "healer", "helper", "her", "his", "i",
|
||
"in", "it", "king", "lady", "lieutenant", "lord", "madam", "man", "men",
|
||
"mr", "mrs", "ms", "mute", "my", "narrator", "narratorold", "of", "officer",
|
||
"officers", "old", "older", "oldest", "on", "onlooker", "or", "orignarrator",
|
||
"our", "prince", "princess", "queen", "rider", "royal", "scavenger",
|
||
"scum", "sergeant", "she", "sir", "skeleton", "skin", "skinny", "smith",
|
||
"soldier", "soldiers", "stranger", "sword", "tallest", "the", "they", "to",
|
||
"troll", "unidentified", "unnamed", "vendor", "voice", "walking", "we",
|
||
"witch", "wolf", "wolves", "woman", "women", "you", "young", "younger",
|
||
"youngest", "your",
|
||
})
|
||
|
||
_OPENING_QUOTES = frozenset({'"', "\u201c", "\u2018", "'"})
|
||
|
||
|
||
def _name_token(name: str) -> str | None:
|
||
"""First-word, casefolded token of a proper-name candidate, or None when
|
||
the candidate carries a digit, a parenthetical (role/scene marker such as
|
||
``Guard 1`` / ``Aide (Royal)``), or a non-name first word."""
|
||
if not name:
|
||
return None
|
||
if any(ch.isdigit() for ch in name) or "(" in name or ")" in name:
|
||
return None
|
||
first = name.split()[0].casefold() if name.split() else ""
|
||
if not first or first in _PAUSE_EXCLUDED_TOKENS:
|
||
return None
|
||
return first
|
||
|
||
|
||
def load_proper_names(data_dir: Path) -> frozenset[str]:
|
||
"""Casefolded first-word tokens of proper names that may begin an utterance.
|
||
|
||
Sources: every speaker's primary name and alias (first word only), plus
|
||
user-confirmed phonetic records (``source == "user"``, e.g. the confirmed
|
||
names Feliu / Hallr / Kaleb / Merial / Sheyla / Xylia). Role labels,
|
||
narrator variants, and common words are excluded so narration such as
|
||
``"The boy ran"`` never gains a spurious pause. Used by
|
||
:func:`prepare_tts_text` to insert the leading ``', '`` pause.
|
||
"""
|
||
tokens: set[str] = set()
|
||
|
||
speakers_path = data_dir / "speakers.json"
|
||
if speakers_path.exists():
|
||
try:
|
||
with open(speakers_path) as f:
|
||
data = json.load(f)
|
||
except (OSError, ValueError):
|
||
data = {}
|
||
for s in data.get("speakers", []):
|
||
if not isinstance(s, dict):
|
||
continue
|
||
for cand in [s.get("primary_name", ""), *s.get("aliases", [])]:
|
||
token = _name_token(cand)
|
||
if token:
|
||
tokens.add(token)
|
||
|
||
for name, record in load_records(data_dir).items():
|
||
if record.get("source") != "user":
|
||
continue
|
||
token = _name_token(name)
|
||
if token:
|
||
tokens.add(token)
|
||
|
||
return frozenset(tokens)
|
||
|
||
|
||
def prefix_proper_name_pause(text: str, proper_names: frozenset[str]) -> str:
|
||
"""Insert ``', '`` before a proper name that opens an utterance.
|
||
|
||
Only the very first word of the utterance is examined (after an optional
|
||
leading quote), so names mentioned later in the line are untouched. Lines
|
||
that already carry the pause (the hand-edited ``, Hallr ...`` rows) pass
|
||
through unchanged. Possessives (``Kaleb's``) are allowed.
|
||
"""
|
||
if not text or not proper_names:
|
||
return text
|
||
|
||
start = 0
|
||
n = len(text)
|
||
while start < n and text[start].isspace():
|
||
start += 1
|
||
if start < n and text[start] in _OPENING_QUOTES:
|
||
start += 1
|
||
while start < n and text[start].isspace():
|
||
start += 1
|
||
if start >= n:
|
||
return text
|
||
if text.startswith(", ", start) or text[start] == ",":
|
||
return text
|
||
|
||
m = re.match(r"[A-Za-z]+", text[start:])
|
||
if not m:
|
||
return text
|
||
if m.group(0).casefold() not in proper_names:
|
||
return text
|
||
return text[:start] + ", " + text[start:]
|
||
|
||
|
||
def prepare_tts_text(
|
||
text: str,
|
||
phonetic: dict[str, str],
|
||
proper_names: frozenset[str] | None = None,
|
||
) -> str:
|
||
"""Full TTS text for an utterance: insert the leading proper-name pause
|
||
(when enabled) before applying phonetics. Every generation path funnels
|
||
through here so markers, staleness checks, and TTS input agree."""
|
||
if proper_names:
|
||
text = prefix_proper_name_pause(text, proper_names)
|
||
return apply_phonetics(text, phonetic)
|
||
|
||
|
||
def ipa_to_simple(ipa: str, word: str = "") -> str:
|
||
"""Convert IPA to an uppercase ASCII TTS-readable respelling.
|
||
|
||
Unmapped non-ASCII characters are dropped; the result is upper-case and
|
||
normalized so it can be safely substituted into synthesis text.
|
||
"""
|
||
if not ipa:
|
||
return ""
|
||
cleaned = ""
|
||
for ch in ipa:
|
||
if ch == "ˈ":
|
||
cleaned += "'"
|
||
elif ch == "ˌ":
|
||
cleaned += "."
|
||
elif (ch.isascii() and ch.isalpha()) or ord(ch) > 127:
|
||
cleaned += ch
|
||
result = cleaned
|
||
for ipa_seq, simple in sorted(_IPA_TO_SIMPLE, key=lambda x: -len(x[0])):
|
||
result = result.replace(ipa_seq, simple)
|
||
result = re.sub(r"[^A-Za-z0-9 .'-]", "", result)
|
||
result = result.strip(" .'-")
|
||
return result.upper()
|
||
|
||
|
||
def _sanitize(name: str) -> str:
|
||
return re.sub(r"[^a-z0-9_]", "_", name.lower().replace(" ", "_")).strip("_")
|
||
|
||
|
||
def pronunciation_audio_path(data_dir: Path, name: str) -> Path:
|
||
return data_dir / PRONUNCIATION_AUDIO_DIR / f"{_sanitize(name)}.wav"
|
||
|
||
|
||
def _urlopen(url: str, timeout: int = 8) -> bytes:
|
||
req = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT})
|
||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||
data: bytes = resp.read()
|
||
return data
|
||
|
||
|
||
def _lookup_dictionaryapi(name: str) -> dict[str, Any]:
|
||
"""Common-word lookup. Hits count as 'well understood' (auto-confirmable)."""
|
||
try:
|
||
raw = _urlopen(DICT_API.format(word=urllib.parse.quote(name)), timeout=5)
|
||
data = json.loads(raw.decode())
|
||
except (urllib.error.HTTPError, urllib.error.URLError, OSError, ValueError) as e:
|
||
return {"ok": False, "error": str(e)}
|
||
if not data or not isinstance(data, list):
|
||
return {"ok": False, "error": "No pronunciation found"}
|
||
for entry in data:
|
||
for p in entry.get("phonetics", []):
|
||
ipa = p.get("text", "")
|
||
if ipa:
|
||
return {
|
||
"ok": True,
|
||
"word": name,
|
||
"ipa": ipa,
|
||
"simple": ipa_to_simple(ipa, name),
|
||
"source": "dictionaryapi",
|
||
"common": True,
|
||
}
|
||
return {"ok": False, "error": "No pronunciation found"}
|
||
|
||
|
||
_WIKTEXT_IPA = re.compile(r"\{\{IPA\|en\|([^|}]+)")
|
||
_WIKTEXT_ENPR = re.compile(r"\{\{enPR\|([^|}]+)")
|
||
|
||
|
||
def _lookup_wiktionary(name: str) -> dict[str, Any]:
|
||
"""Internet lookup for real names/places via the Wiktionary API."""
|
||
params = urllib.parse.urlencode(
|
||
{"action": "parse", "page": name, "prop": "wikitext", "format": "json", "redirects": 1}
|
||
)
|
||
try:
|
||
raw = _urlopen(f"{IPAS}?{params}", timeout=8)
|
||
data = json.loads(raw.decode())
|
||
except (urllib.error.HTTPError, urllib.error.URLError, OSError, ValueError) as e:
|
||
return {"ok": False, "error": str(e)}
|
||
try:
|
||
wikitext = data["parse"]["wikitext"]["*"]
|
||
except (KeyError, TypeError):
|
||
return {"ok": False, "error": "No entry found"}
|
||
|
||
m = _WIKTEXT_IPA.search(wikitext)
|
||
if m:
|
||
ipa = m.group(1).strip().strip("/").split(",")[0].strip()
|
||
if ipa:
|
||
return {
|
||
"ok": True,
|
||
"word": name,
|
||
"ipa": ipa,
|
||
"simple": ipa_to_simple(ipa, name),
|
||
"source": "wiktionary",
|
||
"common": False,
|
||
}
|
||
m = _WIKTEXT_ENPR.search(wikitext)
|
||
if m:
|
||
respelling = m.group(1).strip()
|
||
if respelling:
|
||
return {
|
||
"ok": True,
|
||
"word": name,
|
||
"ipa": respelling,
|
||
"simple": respelling.upper(),
|
||
"source": "wiktionary",
|
||
"common": False,
|
||
}
|
||
return {"ok": False, "error": "No pronunciation in entry"}
|
||
|
||
|
||
_WIKI_IPAC = re.compile(r"\{\{IPAc-en\|([^}]+)\}\}")
|
||
_WIKI_IPA = re.compile(r"\{\{IPA\|(?:en|lang)[^|]*\|/?([^|}]+)")
|
||
_WIKI_LANG_CODES = {"en", "en-us", "en-gb", "en-au", "en-uk", "en-ca"}
|
||
|
||
|
||
def _lookup_wikipedia(name: str) -> dict[str, Any]:
|
||
"""Real names/characters via Wikipedia wikitext (IPAc-en phoneme codes)."""
|
||
params = urllib.parse.urlencode(
|
||
{"action": "parse", "page": name, "prop": "wikitext", "format": "json", "redirects": 1}
|
||
)
|
||
try:
|
||
raw = _urlopen(f"{WIKI_API}?{params}", timeout=8)
|
||
data = json.loads(raw.decode())
|
||
except (urllib.error.HTTPError, urllib.error.URLError, OSError, ValueError) as e:
|
||
return {"ok": False, "error": str(e)}
|
||
try:
|
||
wikitext = data["parse"]["wikitext"]["*"]
|
||
except (KeyError, TypeError):
|
||
return {"ok": False, "error": "No entry found"}
|
||
|
||
m = _WIKI_IPAC.search(wikitext)
|
||
if m:
|
||
parts: list[str] = []
|
||
for token in m.group(1).split("|"):
|
||
token = token.strip()
|
||
if not token or token in _WIKI_LANG_CODES or "=" in token or token in ("ˈ", "ˌ"):
|
||
continue
|
||
simple = ipa_to_simple(token)
|
||
if simple:
|
||
parts.append(simple)
|
||
if parts:
|
||
return {
|
||
"ok": True,
|
||
"word": name,
|
||
"ipa": m.group(1),
|
||
"simple": "-".join(parts),
|
||
"source": "wikipedia",
|
||
"common": False,
|
||
}
|
||
|
||
m = _WIKI_IPA.search(wikitext)
|
||
if m:
|
||
ipa = m.group(1).strip().strip("/").split(",")[0].strip()
|
||
if ipa:
|
||
simple = ipa_to_simple(ipa, name)
|
||
if simple:
|
||
return {
|
||
"ok": True,
|
||
"word": name,
|
||
"ipa": ipa,
|
||
"simple": simple,
|
||
"source": "wikipedia",
|
||
"common": False,
|
||
}
|
||
return {"ok": False, "error": "No pronunciation in entry"}
|
||
|
||
|
||
_BTN_PRONDETAIL = re.compile(r"show_prondetail\(\s*'([^']+)'")
|
||
|
||
|
||
def _lookup_behindthename(name: str) -> dict[str, Any]:
|
||
"""Given names via BehindTheName, whose pages embed English IPA."""
|
||
if not re.fullmatch(r"[a-z]+", name.lower()):
|
||
return {"ok": False, "error": "Not a single-word given name"}
|
||
url = BEHIND_THE_NAME.format(word=name.lower())
|
||
try:
|
||
html = _urlopen(url, timeout=8).decode("utf-8", "replace")
|
||
except (urllib.error.HTTPError, urllib.error.URLError, OSError) as e:
|
||
return {"ok": False, "error": str(e)}
|
||
m = _BTN_PRONDETAIL.search(html)
|
||
if not m:
|
||
return {"ok": False, "error": "No pronunciation on page"}
|
||
ipa = m.group(1).strip()
|
||
simple = ipa_to_simple(ipa, name)
|
||
if not simple:
|
||
return {"ok": False, "error": "Unparseable pronunciation"}
|
||
return {
|
||
"ok": True,
|
||
"word": name,
|
||
"ipa": ipa,
|
||
"simple": simple,
|
||
"source": "behindthename",
|
||
"common": False,
|
||
}
|
||
|
||
|
||
_VOWEL_DIGRAPHS: list[tuple[str, str]] = [
|
||
("augh", "AW"),
|
||
("eigh", "AY"),
|
||
("aigh", "AY"),
|
||
("ough", "OH"),
|
||
("eau", "OH"),
|
||
("ai", "AY"),
|
||
("ay", "AY"),
|
||
("ei", "AY"),
|
||
("ey", "AY"),
|
||
("ee", "EE"),
|
||
("ea", "EE"),
|
||
("ie", "EE"),
|
||
("oa", "OH"),
|
||
("oe", "OH"),
|
||
("ow", "OH"),
|
||
("oo", "OO"),
|
||
("ou", "OW"),
|
||
("oi", "OY"),
|
||
("oy", "OY"),
|
||
("au", "AW"),
|
||
("aw", "AW"),
|
||
("ue", "OO"),
|
||
("eu", "YOO"),
|
||
("ew", "YOO"),
|
||
("ya", "YAH"),
|
||
("ye", "YEH"),
|
||
("yo", "YOH"),
|
||
("yu", "YOO"),
|
||
]
|
||
|
||
_CONSONANT_DIGRAPHS: list[tuple[str, str]] = [
|
||
("ph", "F"),
|
||
("ch", "CH"),
|
||
("sh", "SH"),
|
||
("th", "TH"),
|
||
("wh", "W"),
|
||
("ck", "K"),
|
||
("kn", "N"),
|
||
("wr", "R"),
|
||
("ng", "NG"),
|
||
("qu", "KW"),
|
||
("gh", ""),
|
||
]
|
||
|
||
_VOWEL_TO_LONG: dict[str, str] = {
|
||
"a": "AY",
|
||
"e": "EE",
|
||
"i": "EYE",
|
||
"o": "OH",
|
||
"u": "YOO",
|
||
}
|
||
|
||
_SINGLE_VOWEL: dict[str, str] = {
|
||
"a": "AH",
|
||
"e": "EH",
|
||
"i": "IH",
|
||
"o": "OH",
|
||
"u": "UH",
|
||
}
|
||
|
||
|
||
def _respell_word(word: str) -> str:
|
||
"""Grapheme-to-phoneme guess producing an uppercase TTS-readable respelling."""
|
||
lower = word.lower()
|
||
if not lower:
|
||
return ""
|
||
if lower.startswith("mc"):
|
||
return "MUHK-" + _respell_word(lower[2:])
|
||
if lower.startswith("o'"):
|
||
return "OH-" + _respell_word(lower[2:])
|
||
|
||
tokens: list[tuple[str, bool]] = []
|
||
i = 0
|
||
length = len(lower)
|
||
while i < length:
|
||
ch = lower[i]
|
||
|
||
vowel_digraph: str | None = None
|
||
for vd, out in _VOWEL_DIGRAPHS:
|
||
if lower.startswith(vd, i):
|
||
vowel_digraph = out
|
||
i += len(vd)
|
||
break
|
||
if vowel_digraph is not None:
|
||
tokens.append((vowel_digraph, True))
|
||
continue
|
||
|
||
consonant_digraph: str | None = None
|
||
for cd, out in _CONSONANT_DIGRAPHS:
|
||
if lower.startswith(cd, i):
|
||
consonant_digraph = out
|
||
i += len(cd)
|
||
break
|
||
if consonant_digraph is not None:
|
||
if consonant_digraph:
|
||
tokens.append((consonant_digraph, False))
|
||
continue
|
||
|
||
if ch in _SINGLE_VOWEL:
|
||
nxt = lower[i + 1 : i + 2]
|
||
nxt2 = lower[i + 2 : i + 3] if i + 2 < length else ""
|
||
# Word-final 'a' is an unstressed schwa (Kainda -> KAYN-duh)
|
||
if ch == "a" and i == length - 1:
|
||
tokens.append(("UH", True))
|
||
i += 1
|
||
continue
|
||
# Silent-e lengthens the preceding single vowel: a_e -> AY, i_e -> EYE, ...
|
||
if (
|
||
length - i >= 3
|
||
and nxt in "bcdfghjklmnpqrstvwxyz"
|
||
and nxt2 == "e"
|
||
and (length - (i + 3) == 0)
|
||
):
|
||
tokens.append((_VOWEL_TO_LONG.get(ch, _SINGLE_VOWEL[ch]), True))
|
||
i += 1
|
||
tokens.append((nxt.upper(), False))
|
||
i += 2
|
||
else:
|
||
tokens.append((_SINGLE_VOWEL[ch], True))
|
||
i += 1
|
||
continue
|
||
|
||
if ch == "c":
|
||
nxt = lower[i + 1 : i + 2]
|
||
tokens.append(("S" if nxt in "eiy" else "K", False))
|
||
i += 1
|
||
continue
|
||
|
||
if ch == "y":
|
||
before_vowel = bool(tokens) and tokens[-1][1]
|
||
at_end = i == length - 1
|
||
if not tokens or before_vowel:
|
||
tokens.append(("Y", False))
|
||
elif at_end:
|
||
tokens.append(("EE", True))
|
||
else:
|
||
tokens.append(("IH", True))
|
||
i += 1
|
||
continue
|
||
|
||
if ch == "h" and i == length - 1:
|
||
i += 1
|
||
continue
|
||
|
||
if ch in "bcdfgjklmnpqrstvwxz":
|
||
tokens.append((ch.upper(), False))
|
||
i += 1
|
||
continue
|
||
|
||
i += 1
|
||
|
||
# Collapse duplicate adjacent tokens (e.g. "ll" -> L, "aa" -> AH)
|
||
collapsed: list[tuple[str, bool]] = []
|
||
for ph, is_v in tokens:
|
||
if collapsed and collapsed[-1][0] == ph and collapsed[-1][1] == is_v:
|
||
continue
|
||
collapsed.append((ph, is_v))
|
||
tokens = collapsed
|
||
|
||
# Syllabify: each new vowel starts a syllable; a trailing consonant run
|
||
# splits so the final consonant leads the next syllable (VC-CV).
|
||
vowel_idx = [i for i, (ph, is_v) in enumerate(tokens) if is_v]
|
||
if len(vowel_idx) <= 1:
|
||
return "".join(ph for ph, _ in tokens)
|
||
boundaries: set[int] = set()
|
||
for k in range(1, len(vowel_idx)):
|
||
prev, cur = vowel_idx[k - 1], vowel_idx[k]
|
||
run = tokens[prev + 1 : cur]
|
||
if not run:
|
||
boundaries.add(cur)
|
||
else:
|
||
boundaries.add(cur - 1)
|
||
out = ""
|
||
for idx, (ph, _is_v) in enumerate(tokens):
|
||
if idx in boundaries:
|
||
out += "-"
|
||
out += ph
|
||
return out
|
||
|
||
|
||
def guess_pronunciation(name: str) -> str:
|
||
"""Best-effort English respelling for names with no internet source."""
|
||
words = re.split(r"[\s-]+", name.strip())
|
||
respelled = [_respell_word(w) for w in words if w]
|
||
return " ".join(w for w in respelled if w)
|
||
|
||
|
||
def lookup_pronunciation(name: str) -> dict[str, Any]:
|
||
"""Find the most common pronunciation for a name.
|
||
|
||
Sources, in order of confidence:
|
||
- dictionaryapi hit on the name or its lowercase form => common word,
|
||
well understood, safe to auto-confirm.
|
||
- Wiktionary / Wikipedia / BehindTheName entries => real name or place
|
||
with IPA; needs user confirmation.
|
||
- Heuristic respelling guess => always yields a reviewable candidate so
|
||
the names page is populated even for invented names.
|
||
"""
|
||
for candidate in (name, name.lower()):
|
||
if candidate.strip():
|
||
result = _lookup_dictionaryapi(candidate)
|
||
if result.get("ok"):
|
||
return result
|
||
for lookup in (_lookup_wiktionary, _lookup_wikipedia, _lookup_behindthename):
|
||
result = lookup(name)
|
||
if result.get("ok"):
|
||
return result
|
||
simple = guess_pronunciation(name)
|
||
if simple:
|
||
return {
|
||
"ok": True,
|
||
"word": name,
|
||
"ipa": "",
|
||
"simple": simple,
|
||
"source": "guess",
|
||
"common": False,
|
||
}
|
||
return {"ok": False, "error": "No pronunciation found"}
|
||
|
||
|
||
def _find_narrator_ref(voices_dir: Path) -> Path | None:
|
||
if not voices_dir.exists():
|
||
return None
|
||
for pattern in ("narrator_ref.wav", "kainda_ref.wav"):
|
||
path = voices_dir / pattern
|
||
if path.exists():
|
||
return path
|
||
return None
|
||
|
||
|
||
def _find_speaker_voice(
|
||
name: str, data_dir: Path, voices_dir: Path,
|
||
) -> tuple[Path | None, str | None]:
|
||
"""Look up whether *name* matches a character speaker and return their voice ref + gender.
|
||
|
||
Returns ``(voice_ref_path, gender)`` where gender is ``"male"``,
|
||
``"female"``, or ``None``. Falls back to ``(None, None)`` when no
|
||
speaker match is found.
|
||
"""
|
||
speakers_path = data_dir / "speakers.json"
|
||
if not speakers_path.exists():
|
||
return None, None
|
||
try:
|
||
with open(speakers_path) as f:
|
||
data = json.load(f)
|
||
except (OSError, json.JSONDecodeError):
|
||
return None, None
|
||
|
||
name_lower = name.strip().lower()
|
||
alias_map: dict[str, dict[str, Any]] = {}
|
||
for s in data.get("speakers", []):
|
||
primary = s.get("primary_name", "")
|
||
if primary:
|
||
alias_map[primary.lower()] = s
|
||
for alias in s.get("aliases", []):
|
||
if alias:
|
||
alias_map[alias.lower()] = s
|
||
first_word = primary.split()[0].lower() if primary else ""
|
||
if first_word and first_word not in alias_map:
|
||
alias_map[first_word] = s
|
||
|
||
match = alias_map.get(name_lower)
|
||
if not match:
|
||
return None, None
|
||
|
||
primary_name = match.get("primary_name", "")
|
||
gender = match.get("demographics", {}).get("gender", "Unknown")
|
||
if not voices_dir.exists():
|
||
return None, gender
|
||
ref_name = primary_name.lower().replace(" ", "_") + "_ref.wav"
|
||
ref_path = voices_dir / ref_name
|
||
if ref_path.exists():
|
||
return ref_path, gender
|
||
for vf in voices_dir.glob("*_ref.wav"):
|
||
if primary_name.lower().replace(" ", "_") in vf.stem.lower():
|
||
return vf, gender
|
||
return None, gender
|
||
|
||
|
||
_CARRIER_BY_GENDER: dict[str | None, str] = {
|
||
"female": "Her name is {spelling}.",
|
||
"male": "His name is {spelling}.",
|
||
None: "The name is {spelling}.",
|
||
"Unknown": "The name is {spelling}.",
|
||
}
|
||
|
||
|
||
def generate_pronunciation_audio(config: Any, name: str, spelling: str) -> dict[str, Any]:
|
||
"""Synthesize a preview of how the phonetic spelling will be voiced.
|
||
|
||
When the name matches a character speaker, uses that character's voice
|
||
reference so the preview is representative of chapter audio. A short
|
||
carrier phrase ("Her name is …") gives the TTS engine prosodic context
|
||
so the name is pronounced naturally rather than in isolation.
|
||
"""
|
||
audio_dir = config.data_dir / PRONUNCIATION_AUDIO_DIR
|
||
audio_dir.mkdir(parents=True, exist_ok=True)
|
||
out = pronunciation_audio_path(config.data_dir, name)
|
||
|
||
speaker_ref, gender = _find_speaker_voice(name, config.data_dir, config.voices_dir)
|
||
ref = speaker_ref or _find_narrator_ref(config.voices_dir)
|
||
|
||
carrier = _CARRIER_BY_GENDER.get(gender, _CARRIER_BY_GENDER[None])
|
||
syn_text = carrier.format(spelling=spelling)
|
||
|
||
try:
|
||
from umbraaudio.voices.qwen_tts import VOICE_REFERENCE_TEXT, QwenTTSEngine
|
||
|
||
engine = QwenTTSEngine(qwen_tts_url=config.tts.qwen_tts_url, helper_url=config.tts.qwen_tts_helper_url)
|
||
if ref:
|
||
engine.generate(
|
||
text=syn_text, output_path=out, voice_ref=ref,
|
||
ref_text=VOICE_REFERENCE_TEXT,
|
||
)
|
||
else:
|
||
engine.generate(
|
||
text=syn_text,
|
||
output_path=out,
|
||
instruct="A calm, neutral voice speaking clearly.",
|
||
)
|
||
if out.exists():
|
||
return {"ok": True, "audio": out.name}
|
||
return {"ok": False, "error": "No audio produced"}
|
||
except Exception as e:
|
||
logger.error("Pronunciation audio failed for %s: %s", name, e)
|
||
return {"ok": False, "error": str(e)}
|