Files
UmbraAudio/tests/test_pronunciation.py
B.D. Murphy c86e323d7a Initial commit: UmbraAudio audiobook generation program
Quality-first multi-pass audiobook generation from English manuscripts.

Includes:
- umbraaudio/ core package (pipeline, config, voices, web review UI)
- scripts/ CLI utilities + model download script
- tts_server/ remote TTS server
- specs/ design docs, tests/, pyproject.toml + uv.lock
- Qwen3-TTS git submodule (pinned 022e286) with local demo.py UI patch
  saved at scripts/qwen3_tts_demo_umbra_patch.patch
- .env with runtime config/secrets

Excluded (see .gitignore): models/ (downloaded via scripts/download_models.sh),
output/, logs/, .venv, .umbra runtime data, and book projects
(DealHunterShip_*), which are committed separately.
2026-08-30 07:58:24 -07:00

459 lines
16 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
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"
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 "")