Files
UmbraAudio/umbraaudio/pronunciation.py
B.D. Murphy 0c9bea72e0 Add enterprise/infra tooling and repoint Qwen3-TTS submodule to internal fork
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.
2026-10-06 06:57:41 -07:00

829 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)}