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