580 lines
21 KiB
Python
580 lines
21 KiB
Python
# 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 "")
|