Files
UmbraAudio/tests/test_pronunciation.py
2026-10-18 19:01:04 -07:00

580 lines
21 KiB
Python
Raw Permalink 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.

# Copyright 2026 Umbra Tools (umbratools.com)
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
import json
from types import SimpleNamespace
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from pathlib import Path
from umbraaudio import pronunciation as pr
def _write_json(path: Path, data: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w") as f:
json.dump(data, f)
class TestLoadRecords:
def test_migrates_legacy_strings(self, temp_dir: Path):
_write_json(
temp_dir / pr.PHONETIC_NAMES_FILE,
{
"Kainda": "KAEIN-duh",
"Weston": {
"phonetic": "WES-ton",
"source": "wiktionary",
"confirmed": True,
"common": False,
},
},
)
records = pr.load_records(temp_dir)
assert records["Kainda"] == {
"phonetic": "KAEIN-duh",
"source": "legacy",
"confirmed": False,
"common": False,
}
assert records["Weston"]["confirmed"] is True
assert records["Weston"]["source"] == "wiktionary"
def test_missing_file_returns_empty(self, temp_dir: Path):
assert pr.load_records(temp_dir) == {}
def test_bad_json_returns_empty(self, temp_dir: Path):
_write_json(temp_dir / pr.PHONETIC_NAMES_FILE, "not json") # type: ignore[arg-type]
assert pr.load_records(temp_dir) == {}
def test_round_trip(self, temp_dir: Path):
records = {
"Kainda": {
"phonetic": "KAEIN-duh",
"source": "user",
"confirmed": True,
"common": False,
},
}
pr.save_records(temp_dir, records)
assert pr.load_records(temp_dir) == records
class TestLoadPhoneticNames:
def test_only_confirmed(self, temp_dir: Path):
_write_json(
temp_dir / pr.PHONETIC_NAMES_FILE,
{
"Kainda": {
"phonetic": "KAEIN-duh",
"source": "wiktionary",
"confirmed": False,
"common": False,
},
"Weston": {
"phonetic": "WES-ton",
"source": "wiktionary",
"confirmed": True,
"common": False,
},
"They": "THAY",
},
)
assert pr.load_phonetic_names(temp_dir) == {"Weston": "WES-ton"}
def test_empty_spelling_not_returned(self, temp_dir: Path):
_write_json(
temp_dir / pr.PHONETIC_NAMES_FILE,
{"Kainda": {"phonetic": "", "source": "", "confirmed": True, "common": False}},
)
assert pr.load_phonetic_names(temp_dir) == {}
def test_dictionaryapi_common_words_excluded_by_default(self, temp_dir: Path):
_write_json(
temp_dir / pr.PHONETIC_NAMES_FILE,
{
"Author": {
"phonetic": "AWTHUH",
"source": "dictionaryapi",
"confirmed": True,
"common": True,
},
"Weston": {
"phonetic": "WES-ton",
"source": "wiktionary",
"confirmed": True,
"common": False,
},
"Kainda": {
"phonetic": "KAEIN-duh",
"source": "user",
"confirmed": True,
"common": False,
},
},
)
assert pr.load_phonetic_names(temp_dir) == {
"Weston": "WES-ton",
"Kainda": "KAEIN-duh",
}
assert pr.load_phonetic_names(temp_dir, include_common=True) == {
"Author": "AWTHUH",
"Weston": "WES-ton",
"Kainda": "KAEIN-duh",
}
def test_user_override_of_common_word_is_applied(self, temp_dir: Path):
_write_json(
temp_dir / pr.PHONETIC_NAMES_FILE,
{
"Author": {
"phonetic": "AW-THUH",
"source": "user",
"confirmed": True,
"common": False,
},
},
)
assert pr.load_phonetic_names(temp_dir) == {"Author": "AW-THUH"}
class TestIpaToSimple:
def test_digraphs(self):
assert pr.ipa_to_simple("eɪ") == "AY"
assert pr.ipa_to_simple("tʃ") == "CH"
assert pr.ipa_to_simple("dʒæk") == "JAEK"
assert pr.ipa_to_simple("θæŋks") == "THAENGKS"
assert pr.ipa_to_simple("wɪnd") == "WIHND"
assert pr.ipa_to_simple("kæt") == "KAET"
def test_stress_markers_and_strip(self):
assert pr.ipa_to_simple("ˈkæɪndə") == "KAEIHNDUH"
def test_unicode_single_storey_g_retained(self):
assert pr.ipa_to_simple("/ˈɡuːɡəl/") == "GOOGUHL"
def test_wh_phoneme_retained(self):
assert pr.ipa_to_simple("/ʍɛn/") == "WEHN"
def test_glottal_stop_and_syllabic_n(self):
assert pr.ipa_to_simple("/ˈbʌʔn̩/") == "BUH'N"
def test_empty(self):
assert pr.ipa_to_simple("") == ""
class TestApplyPhonetics:
def test_longest_first(self):
phonetic = {"Captain Weston": "KAP-tin WES-ton", "Weston": "WES-ton"}
text = "Captain Weston greeted Weston."
assert pr.apply_phonetics(text, phonetic) == "KAP-tin WES-ton greeted WES-ton."
def test_no_map(self):
assert pr.apply_phonetics("Hello world", {}) == "Hello world"
def test_curly_punctuation_normalized(self):
text = "“Author’s Note”—it’s his…"
assert pr.apply_phonetics(text, {}) == "\"Author's Note\"-it's his..."
assert pr.apply_phonetics("don’t won’t", {}) == "don't won't"
def _write_speakers(temp_dir: Path) -> None:
_write_json(
temp_dir / "speakers.json",
{
"speakers": [
{"primary_name": "Kaleb", "aliases": ["The young man"]},
{"primary_name": "Hallr", "aliases": []},
{"primary_name": "Sheyla", "aliases": ["Healer", "The woman"]},
{"primary_name": "Captain Ulgary", "aliases": []},
{"primary_name": "Guard 1", "aliases": []},
{"primary_name": "Narrator", "aliases": []},
{"primary_name": "scum leader", "aliases": []},
]
},
)
class TestLoadProperNames:
def test_primary_names_and_alias_first_words(self, temp_dir: Path):
_write_speakers(temp_dir)
names = pr.load_proper_names(temp_dir)
assert "kaleb" in names
assert "hallr" in names
assert "sheyla" in names
# role/title/unknown words are excluded
assert "captain" not in names
assert "healer" not in names
assert "guard" not in names
assert "narrator" not in names
assert "scum" not in names
assert "the" not in names
def test_user_confirmed_phonetic_names_included(self, temp_dir: Path):
_write_json(
temp_dir / pr.PHONETIC_NAMES_FILE,
{
"Feliu": {
"phonetic": "FEH-lee-oo",
"source": "user",
"confirmed": True,
"common": False,
},
"Meriel": {
"phonetic": "MEH-ree-el",
"source": "behindthename",
"confirmed": False,
"common": False,
},
},
)
names = pr.load_proper_names(temp_dir)
assert "feliu" in names
assert "meriel" not in names
def test_missing_files_empty(self, temp_dir: Path):
assert pr.load_proper_names(temp_dir) == frozenset()
class TestPrefixProperNamePause:
def test_narration_start(self):
names = frozenset({"kaleb", "hallr", "sheyla"})
assert pr.prefix_proper_name_pause("Kaleb stood by the fire.", names) == (
", Kaleb stood by the fire."
)
def test_dialogue_after_open_quote(self):
names = frozenset({"kaleb"})
assert pr.prefix_proper_name_pause("“Kaleb, come here.”", names) == (
"“, Kaleb, come here.”"
)
def test_already_prefixed_untouched(self):
names = frozenset({"hallr"})
assert pr.prefix_proper_name_pause(", Hallr, with his axe, stood.", names) == (
", Hallr, with his axe, stood."
)
assert pr.prefix_proper_name_pause("“, Hallr, what do you mean?”", names) == (
"“, Hallr, what do you mean?”"
)
def test_non_proper_name_untouched(self):
names = frozenset({"kaleb"})
assert pr.prefix_proper_name_pause("The boy ran home.", names) == "The boy ran home."
assert pr.prefix_proper_name_pause("“What is Kaleb doing?”", names) == (
"“What is Kaleb doing?”"
)
def test_possessive_matches(self):
names = frozenset({"xylia"})
assert pr.prefix_proper_name_pause("Xylia's wing healed.", names) == (
", Xylia's wing healed."
)
def test_empty_inputs(self):
assert pr.prefix_proper_name_pause("", frozenset({"kaleb"})) == ""
assert pr.prefix_proper_name_pause("Kaleb nods.", frozenset()) == "Kaleb nods."
class TestPrepareTtsText:
def test_pause_then_phonetics(self):
phonetic = {"Kaleb": "KAYLEB", "Hallr": "HALR"}
names = frozenset({"kaleb", "hallr"})
assert pr.prepare_tts_text("Kaleb replies,", phonetic, names) == (
", KAYLEB replies,"
)
def test_no_proper_names_skips_pause(self):
assert pr.prepare_tts_text("The boy ran.", {"Weston": "WES-ton"}, frozenset()) == (
"The boy ran."
)
def test_matches_apply_phonetics_without_pause(self):
phonetic = {"Kainda": "KAEIN-duh"}
assert pr.prepare_tts_text("Kainda steps forward.", phonetic) == (
pr.apply_phonetics("Kainda steps forward.", phonetic)
)
class TestNormalizeTtsText:
def test_ascii_identity(self):
assert pr.normalize_tts_text("Plain ASCII text.") == "Plain ASCII text."
def test_curly_to_ascii(self):
assert pr.normalize_tts_text("\u2018a\u2019 \u201cb\u201d \u2013\u2014\u2026") == (
"'a' \"b\" --..."
)
def test_accents_preserved(self):
assert pr.normalize_tts_text("Čapek and Köhler") == "Čapek and Köhler"
class TestLookup:
def test_dictionary_hit_wins(self, monkeypatch):
dict_hit = {"ok": True, "simple": "THAY", "source": "dictionaryapi", "common": True}
monkeypatch.setattr(pr, "_lookup_dictionaryapi", lambda name: dict_hit)
result = pr.lookup_pronunciation("They")
assert result["ok"] is True
assert result["common"] is True
assert result["source"] == "dictionaryapi"
def test_falls_back_to_wiktionary(self, monkeypatch):
monkeypatch.setattr(pr, "_lookup_dictionaryapi", lambda name: {"ok": False, "error": "no"})
wiki_hit = {"ok": True, "simple": "KAYN-duh", "source": "wiktionary", "common": False}
monkeypatch.setattr(pr, "_lookup_wiktionary", lambda name: wiki_hit)
result = pr.lookup_pronunciation("Kainda")
assert result["ok"] is True
assert result["source"] == "wiktionary"
assert result["common"] is False
def test_lowercase_fallback_attempted(self, monkeypatch):
calls = []
def fake_dict(name: str) -> dict:
calls.append(name)
return {"ok": False, "error": "no"}
monkeypatch.setattr(pr, "_lookup_dictionaryapi", fake_dict)
monkeypatch.setattr(
pr,
"_lookup_wiktionary",
lambda name: {"ok": False, "error": "no entry"},
)
monkeypatch.setattr(
pr,
"_lookup_wikipedia",
lambda name: {"ok": False, "error": "no entry"},
)
monkeypatch.setattr(
pr,
"_lookup_behindthename",
lambda name: {"ok": False, "error": "no entry"},
)
result = pr.lookup_pronunciation("Kainda")
assert result["ok"] is True
assert result["source"] == "guess"
assert calls == ["Kainda", "kainda"]
def test_wiktionary_ipa_parse(self, monkeypatch):
def fake_urlopen(url: str, timeout: int = 8) -> bytes:
return json.dumps({"parse": {"wikitext": {"*": "{{IPA|en|/ˈkæɪndə/}}"}}}).encode()
monkeypatch.setattr(pr, "_urlopen", fake_urlopen)
result = pr._lookup_wiktionary("Kainda")
assert result["ok"] is True
assert result["source"] == "wiktionary"
assert result["common"] is False
assert "KAEIHNDUH" in result["simple"]
def test_wiktionary_enpr_fallback(self, monkeypatch):
def fake_urlopen(url: str, timeout: int = 8) -> bytes:
return json.dumps({"parse": {"wikitext": {"*": "{{enPR|kān-də}}"}}}).encode()
monkeypatch.setattr(pr, "_urlopen", fake_urlopen)
result = pr._lookup_wiktionary("Kainda")
assert result["ok"] is True
assert result["simple"] == "KĀN-DƏ".upper()
def test_wiktionary_no_entry(self, monkeypatch):
def fake_urlopen(url: str, timeout: int = 8) -> bytes:
return b"{}"
monkeypatch.setattr(pr, "_urlopen", fake_urlopen)
result = pr._lookup_wiktionary("FictionalName")
assert result["ok"] is False
class TestNewLookupSources:
def test_wikipedia_ipac_en(self, monkeypatch):
def fake_urlopen(url: str, timeout: int = 8) -> bytes:
return json.dumps(
{"parse": {"wikitext": {"*": "{{IPAc-en|ɑ:|ˈ|l|iː|ə|audio=En-usAaliyah.ogg}}"}}}
).encode()
monkeypatch.setattr(pr, "_urlopen", fake_urlopen)
result = pr._lookup_wikipedia("Aaliyah")
assert result["ok"] is True
assert result["source"] == "wikipedia"
assert result["common"] is False
assert result["simple"] == "AH-L-EE-UH"
def test_wikipedia_ipa_template(self, monkeypatch):
def fake_urlopen(url: str, timeout: int = 8) -> bytes:
return json.dumps(
{"parse": {"wikitext": {"*": "{{Infobox|pron={{IPA|en|/ˈkæɪndə/}}}}"}}}
).encode()
monkeypatch.setattr(pr, "_urlopen", fake_urlopen)
result = pr._lookup_wikipedia("Kainda")
assert result["ok"] is True
assert result["source"] == "wikipedia"
assert "KAEIHNDUH" in result["simple"]
def test_wikipedia_no_entry(self, monkeypatch):
def fake_urlopen(url: str, timeout: int = 8) -> bytes:
return b"{}"
monkeypatch.setattr(pr, "_urlopen", fake_urlopen)
result = pr._lookup_wikipedia("FictionalName")
assert result["ok"] is False
def test_behindthename(self, monkeypatch):
html = (
'<a href="#" class="prn ipa" onclick="return show_prondetail('
"'ˈmaɪ.kəl', 1, true, true, 'eng');\">"
)
def fake_urlopen(url: str, timeout: int = 8) -> bytes:
return html.encode()
monkeypatch.setattr(pr, "_urlopen", fake_urlopen)
result = pr._lookup_behindthename("Michael")
assert result["ok"] is True
assert result["source"] == "behindthename"
assert "MEYE" in result["simple"]
def test_behindthename_no_hit(self, monkeypatch):
def fake_urlopen(url: str, timeout: int = 8) -> bytes:
return b"<html>nothing here</html>"
monkeypatch.setattr(pr, "_urlopen", fake_urlopen)
result = pr._lookup_behindthename("FictionalName")
assert result["ok"] is False
def test_behindthename_skips_multiword(self, monkeypatch):
def fake_urlopen(url: str, timeout: int = 8) -> bytes:
raise AssertionError("should not be called for multi-word names")
monkeypatch.setattr(pr, "_urlopen", fake_urlopen)
result = pr._lookup_behindthename("Captain Weston")
assert result["ok"] is False
def test_guess_is_used_as_last_resort(self, monkeypatch):
sources = (
"_lookup_dictionaryapi",
"_lookup_wiktionary",
"_lookup_wikipedia",
"_lookup_behindthename",
)
for fn in sources:
monkeypatch.setattr(pr, fn, lambda name: {"ok": False, "error": "no"})
result = pr.lookup_pronunciation("Zorath")
assert result["ok"] is True
assert result["source"] == "guess"
assert result["simple"] == result["simple"].upper()
class TestGuessPronunciation:
def test_syllabified_uppercase(self):
assert pr.guess_pronunciation("Kainda") == "KAYN-DUH"
def test_weston(self):
assert pr.guess_pronunciation("Weston") == "WEHS-TOHN"
def test_single_syllable_no_hyphen(self):
assert pr.guess_pronunciation("Smith") == "SMIHTH"
def test_silent_e_lengthens(self):
assert pr.guess_pronunciation("Mike") == "MEYEK"
def test_double_consonant_collapsed(self):
assert pr.guess_pronunciation("Miller") == "MIH-LEHR"
def test_multiword(self):
assert pr.guess_pronunciation("Captain Weston") == "KAHP-TAYN WEHS-TOHN"
def test_mc_and_o_prefixes(self):
assert pr.guess_pronunciation("O'Brien") == "OH-BREEN"
assert pr.guess_pronunciation("McLeod") == "MUHK-LEH-OHD"
def test_soft_c_before_e(self):
assert pr.guess_pronunciation("Cindy") == "SIHN-DEE"
def test_final_a_schwa(self):
assert pr.guess_pronunciation("Kainda") == "KAYN-DUH"
assert pr.guess_pronunciation("Aaliyah") == "AH-LIH-YAH"
def test_empty(self):
assert pr.guess_pronunciation("") == ""
assert pr.guess_pronunciation(" ") == ""
class TestPaths:
def test_sanitize(self):
assert pr._sanitize("Captain Weston") == "captain_weston"
assert pr._sanitize("O'Brien") == "o_brien"
def test_audio_path_deterministic(self, temp_dir: Path):
p1 = pr.pronunciation_audio_path(temp_dir, "Captain Weston")
p2 = pr.pronunciation_audio_path(temp_dir, "Captain Weston")
assert p1 == p2 == temp_dir / "pronunciation_audio" / "captain_weston.wav"
class TestNarratorRef:
def test_prefers_narrator(self, temp_dir: Path):
voices = temp_dir / "voices"
voices.mkdir()
(voices / "kainda_ref.wav").write_bytes(b"wav")
narrator = voices / "narrator_ref.wav"
narrator.write_bytes(b"wav")
assert pr._find_narrator_ref(voices) == narrator
def test_falls_back_to_kainda(self, temp_dir: Path):
voices = temp_dir / "voices"
voices.mkdir()
kainda = voices / "kainda_ref.wav"
kainda.write_bytes(b"wav")
assert pr._find_narrator_ref(voices) == kainda
def test_missing_dir(self, temp_dir: Path):
assert pr._find_narrator_ref(temp_dir / "nope") is None
class TestGenerateAudio:
def _config(self, temp_dir: Path) -> SimpleNamespace:
return SimpleNamespace(
data_dir=temp_dir,
voices_dir=temp_dir / "voices",
tts=SimpleNamespace(qwen_tts_url="http://tts:8881"),
)
def test_success_with_narrator_ref(self, temp_dir: Path, monkeypatch):
config = self._config(temp_dir)
config.voices_dir.mkdir()
ref = config.voices_dir / "narrator_ref.wav"
ref.write_bytes(b"wav")
class FakeEngine:
def __init__(self, **kwargs: object) -> None:
self.kwargs = kwargs
def generate(self, text: str, output_path: Path, voice_ref=None, instruct=None, ref_text=None) -> Path:
output_path.write_bytes(b"RIFF")
return output_path
monkeypatch.setattr("umbraaudio.voices.qwen_tts.QwenTTSEngine", FakeEngine)
result = pr.generate_pronunciation_audio(config, "Captain Weston", "KAP-tin WES-ton")
assert result["ok"] is True
assert result["audio"] == "captain_weston.wav"
assert (temp_dir / "pronunciation_audio" / "captain_weston.wav").exists()
def test_failure_is_captured(self, temp_dir: Path, monkeypatch):
config = self._config(temp_dir)
config.voices_dir.mkdir()
(config.voices_dir / "narrator_ref.wav").write_bytes(b"wav")
class FailingEngine:
def __init__(self, **kwargs: object) -> None:
pass
def generate(self, **kwargs: object) -> Path:
raise RuntimeError("cuDNN CUDNN_STATUS_INTERNAL_ERROR")
monkeypatch.setattr("umbraaudio.voices.qwen_tts.QwenTTSEngine", FailingEngine)
result = pr.generate_pronunciation_audio(config, "Captain Weston", "KAP-tin WES-ton")
assert result["ok"] is False
assert "cuDNN" in (result["error"] or "")