Text processing before TTS:
- dictionary.txt rewrites words and phrases (km/h, np., units). Rules compile
into one alternation applied in a single pass, so replacements never chain
and longest-literal wins. Case-sensitive, whole-word.
- A per-queue second dictionary, written by the GUI into the first video's job
folder and merged with the shared one (--dictionary-extra).
- ALL-CAPS blocks become Title Case rather than sentence case: lowercasing
destroyed proper nouns inside captions ("NASA - MARGO MADISON") and invited
the normalizer to "correct" the name. Paired clips in output/clips/all-caps
showed the TTS reads both casings the same.
normalizer/ — digits to spoken Polish, in its own venv because it needs
transformers>=5, which must not touch ComfyUI's pinned versions:
- Two backends. Plain integers go to num2words for the value and a general
Polish LLM for the case; times, dates, decimals and identifiers go to a 0.6B
fine-tune. Measured on 40 real subtitle lines, each is clearly better than
the other on its half. 74% of numbered blocks take the first leg.
- Guards on every answer: runaway length, repetition loops, invalid bytes,
leftover digits, a lost minus sign, a changed number, a lost word. A failure
falls back to num2words, which is nominative but never wrong about the value.
- ComfyUI is asked to unload before normalizing; a normalizer that does not fit
in VRAM is spilled to RAM silently at 5x the cost.
Backend lifecycle:
- ComfyUI is found by port through /proc, so a backend started by hand is
managed too. SIGTERM escalates to SIGKILL, and a suspended process is killed
outright since it never runs its handlers.
- A clip that kills the backend is retried three times, each with a different
seed (the aborts are seed-dependent, not text-dependent), then skipped and
reported so an overnight queue keeps moving.
- start_comfyui.sh execs python, so the script's PID is ComfyUI's.
- GUI gains ComfyUI and Queue dictionary buttons; quitting stops the backend.
--resume-from now reuses the job folder's tts_input.srt instead of reparsing
the subtitles, so block N still pairs with clip_NNNN.flac.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
376 lines
16 KiB
Python
376 lines
16 KiB
Python
#!/usr/bin/env python3
|
|
"""Acceptance checks for the Polish number normalizer.
|
|
|
|
The model produces valid case variants, so these assert on *properties* — a
|
|
digit is gone, a word stem is present — never on an exact string, except where
|
|
the behaviour genuinely is exact (model-not-called, pass-through).
|
|
|
|
Run them in the normalizer venv:
|
|
|
|
normalizer/venv/bin/python -m pytest normalizer/tests -v
|
|
|
|
They pass with networking disabled. Tests that need the neural path are skipped
|
|
automatically when the model has not been downloaded; the rest exercise the
|
|
fallback and the protect/decide/restore logic, which is where the integration
|
|
bugs actually live.
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
import sys
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
|
|
os.environ.setdefault("HF_HUB_OFFLINE", "1")
|
|
os.environ.setdefault("TRANSFORMERS_OFFLINE", "1")
|
|
|
|
import normalizer # noqa: E402
|
|
|
|
HAVE_MODEL = normalizer.model_available()
|
|
needs_model = unittest.skipUnless(
|
|
HAVE_MODEL, "model not downloaded — see normalizer/README.md")
|
|
|
|
DIGITS = re.compile(r'\d')
|
|
|
|
|
|
class Selection(unittest.TestCase):
|
|
"""Which blocks are worth sending at all. lektor_ai_comfy.py mirrors this
|
|
rule to keep blocks that need nothing from crossing the process boundary."""
|
|
|
|
def test_single_digit_is_not_worth_normalizing(self):
|
|
self.assertFalse(normalizer.needs_normalization("Mam 3 koty."))
|
|
|
|
def test_no_digits_at_all(self):
|
|
self.assertFalse(normalizer.needs_normalization("Zwykłe zdanie."))
|
|
|
|
def test_multi_digit(self):
|
|
self.assertTrue(normalizer.needs_normalization("Wygrał 1247 złotych."))
|
|
|
|
def test_complex_expressions_are_not_single_digits(self):
|
|
for text in ("Spotkanie o 9:30.", "Około 3,5 metra.", "Mieszka pod 12/45.",
|
|
"O 14:30 w środę."):
|
|
with self.subTest(text=text):
|
|
self.assertTrue(normalizer.needs_normalization(text))
|
|
|
|
def test_thousands_with_spaces(self):
|
|
self.assertTrue(normalizer.needs_normalization("populacja 1 350 000 osób"))
|
|
|
|
def test_single_digit_before_punctuation_is_still_single(self):
|
|
for text in ("Mam 3, potem 4.", "Zostało 5.", "Tylko 7!"):
|
|
with self.subTest(text=text):
|
|
self.assertFalse(normalizer.needs_normalization(text))
|
|
|
|
def test_multi_digit_before_punctuation_is_caught(self):
|
|
self.assertTrue(normalizer.needs_normalization("Przód 85, tył 75."))
|
|
|
|
|
|
class ClientRuleInSync(unittest.TestCase):
|
|
"""lektor_ai_comfy.py keeps its own copy of the selection rule so blocks
|
|
that need nothing never start a subprocess. A copy that drifts sends the
|
|
wrong blocks — or silently stops sending any — so it is checked here, where
|
|
the authoritative rule lives. The caller is pure standard library, so this
|
|
venv can import it."""
|
|
|
|
SAMPLES = [
|
|
"Mam 3 koty.", "Zwykłe zdanie.", "Wygrał 1247 złotych.",
|
|
"Przód 85, tył 75.", "populacja 1 350 000 osób", "Radio 95,7 FM.",
|
|
"Spotkanie o 14:30.", "Mieszka pod 12/45.", "Około 3,5 metra.",
|
|
"Formuła 1", "Masz F2, masz F3.", "Zostało 5.", "Mam 3, potem 4.",
|
|
"W 2026 roku.", "Tylko 7!", "1 pies, 2 koty, 300 myszy.",
|
|
]
|
|
|
|
def test_same_verdict_on_every_sample(self):
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
|
try:
|
|
import lektor_ai_comfy
|
|
except ImportError as exc: # pragma: no cover - only if moved
|
|
self.skipTest(f"caller not importable: {exc}")
|
|
for text in self.SAMPLES:
|
|
with self.subTest(text=text):
|
|
self.assertEqual(lektor_ai_comfy.needs_number_normalization(text),
|
|
normalizer.needs_normalization(text))
|
|
|
|
|
|
class Boundaries(unittest.TestCase):
|
|
"""KEEP_RE no longer shields anything — it only decides whether a block is
|
|
worth sending. These pin down where a number starts and ends."""
|
|
|
|
def test_grouped_thousands_are_not_split(self):
|
|
"""The leading 1 of "1 350 000" is not a standalone single digit; if it
|
|
counted as one the block might not be sent at all."""
|
|
protected = normalizer.KEEP_RE.sub("X", "populacja 1 350 000 osób")
|
|
self.assertIn("1 350 000", protected)
|
|
|
|
def test_complex_numbers_are_not_single_digits(self):
|
|
protected = normalizer.KEEP_RE.sub("X", "Mam 3 koty, 25 psów i 3,5 kg.")
|
|
self.assertNotIn(" 3 ", protected) # standalone single digit
|
|
self.assertIn("25", protected) # multi-digit untouched
|
|
self.assertIn("3,5", protected) # decimal untouched
|
|
|
|
|
|
class Routing(unittest.TestCase):
|
|
"""Which leg a block goes to. The two models fail in opposite directions,
|
|
so a misrouted block gets the worse of the two."""
|
|
|
|
PLAIN = ["Wygrał 1247 złotych.", "Mówili o 250000 uchodźcach.",
|
|
"Przód 85, tył 75.", "Małpi gaj za 150 milionów dolarów.",
|
|
"Bardzo. Ma z 80 lat.", "populacja 1 350 000 osób",
|
|
# A range written in words is two plain integers; only a dash
|
|
# between digits makes it a compound the fine-tune reads better.
|
|
"Ma od 15 do 20 lat."]
|
|
COMPLEX = ["Spotkanie o 14:30.", "Radio 95,7 FM.", "Mieszka pod 12/45.",
|
|
"Teraz na P12.", "Lot LO 3842 jest opóźniony.",
|
|
"Gdzie 13 maja 1950 roku się zaczęło.", "Ma od 10-20 lat.",
|
|
"Silnik ma 1998 cm3 pojemności.", "W 2026 roku."]
|
|
|
|
def test_plain(self):
|
|
for text in self.PLAIN:
|
|
with self.subTest(text=text):
|
|
self.assertEqual(normalizer.route(text), "plain")
|
|
|
|
def test_complex(self):
|
|
for text in self.COMPLEX:
|
|
with self.subTest(text=text):
|
|
self.assertEqual(normalizer.route(text), "complex")
|
|
|
|
def test_case_sensitivity_of_the_call_sign_rule(self):
|
|
"""Applied case-insensitively this rule matched any short word before a
|
|
number and misrouted a third of the sample."""
|
|
self.assertEqual(normalizer.route("Lot LO 3842 wystartował."), "complex")
|
|
self.assertEqual(normalizer.route("Zapłacił za 150 złotych."), "plain")
|
|
|
|
|
|
class Numerals(unittest.TestCase):
|
|
"""The value guard needs numeral identity across inflection, which no
|
|
consonant rule provides: dwieście/dwustu are one number, trzy/trzysta are
|
|
not."""
|
|
|
|
SAME = [("dwieście", "dwustu"), ("pięćdziesiąt", "pięćdziesięciu"),
|
|
("trzydzieści", "trzydziestu"), ("czterdzieści", "czterdziestu"),
|
|
("tysiąc", "tysiącach"), ("milion", "milionach"),
|
|
("osiem", "ośmiu"), ("pięć", "piątym"), ("dwadzieścia", "dwudziesty")]
|
|
DIFFERENT = [("trzy", "trzysta"), ("pięć", "pięćdziesiąt"),
|
|
("dwa", "dwieście"), ("milion", "miliard"),
|
|
("sto", "sześćset"), ("cztery", "czternaście")]
|
|
|
|
def test_same_number(self):
|
|
for a, b in self.SAME:
|
|
with self.subTest(pair=(a, b)):
|
|
self.assertEqual(normalizer._numeral(a), normalizer._numeral(b))
|
|
self.assertTrue(normalizer._numeral(a))
|
|
|
|
def test_different_numbers(self):
|
|
for a, b in self.DIFFERENT:
|
|
with self.subTest(pair=(a, b)):
|
|
self.assertNotEqual(normalizer._numeral(a), normalizer._numeral(b))
|
|
|
|
def test_ordinary_words_are_not_numerals(self):
|
|
for word in ("koty", "uchodźcach", "okrążenie", "prędkość"):
|
|
self.assertEqual(normalizer._numeral(word), "")
|
|
|
|
|
|
class Guards(unittest.TestCase):
|
|
"""Every check here fired on a real failure during development. The
|
|
false-positive cases matter just as much: a guard that rejects good output
|
|
silently downgrades the run."""
|
|
|
|
def test_accepts_correct_output(self):
|
|
for original, candidate in [
|
|
("Mówili o 250000 uchodźcach.",
|
|
"Mówili o dwustu pięćdziesięciu tysiącach uchodźców."),
|
|
("To wypis sprzed 30 lat.", "To wypis sprzed trzydziestu lat."),
|
|
("kolizja na 55 okrążeniu", "kolizja na pięćdziesiątym piątym okrążeniu"),
|
|
("misja to Apollo 25", "misja to Apollo dwudziesty piąty"),
|
|
]:
|
|
with self.subTest(candidate=candidate):
|
|
self.assertEqual(normalizer._guard(original, candidate, True), "")
|
|
|
|
def test_value_changed(self):
|
|
reason = normalizer._guard("Z 8250 osobami.",
|
|
"Z osiem tysięcy dwustu pięciu osobami.", True)
|
|
self.assertIn("numbers changed", reason)
|
|
|
|
def test_added_numeral(self):
|
|
"""The check must run both ways. "dwadzieścia" -> "dwadzieścia pięć"
|
|
keeps every expected word and still turns 20 into 25 — a one-directional
|
|
subset check passed this and shipped 25 years of service as 20."""
|
|
reason = normalizer._guard("Nagroda Za 20 Lat Pracy.",
|
|
"Nagroda Za dwadzieścia Pięć Lat Pracy.", True)
|
|
self.assertIn("added", reason)
|
|
|
|
def test_single_digits_count_toward_the_expectation(self):
|
|
"""num2words skips one-digit numbers but the model expands them, so the
|
|
expectation must cover them or "trzy" looks like an invention."""
|
|
self.assertEqual(normalizer._guard("Mam 3 koty i 25 psów.",
|
|
"Mam trzy koty i dwadzieścia pięć psów.",
|
|
True), "")
|
|
|
|
def test_word_lost(self):
|
|
reason = normalizer._guard("Populacja to 38000000 mieszkańców.",
|
|
"Populacja to trzydzieści osiem milionów.", True)
|
|
self.assertIn("word lost", reason)
|
|
|
|
def test_minus_lost(self):
|
|
reason = normalizer._guard("Na dworze jest -12 stopni.",
|
|
"Na dworze jest dwanaście stopni.", True)
|
|
self.assertIn("minus", reason)
|
|
|
|
def test_repetition_loop(self):
|
|
reason = normalizer._guard("Zapłaciłem 1234,56 zł.",
|
|
"Zapłaciłem " + "łącznie " * 40 + "tysiąc.", False)
|
|
self.assertTrue(reason)
|
|
|
|
def test_invalid_characters(self):
|
|
self.assertIn("invalid", normalizer._guard("Dnia 15 września 1631 roku.",
|
|
"Dnia " + "�" * 30, False))
|
|
|
|
def test_digits_left(self):
|
|
self.assertIn("digits", normalizer._guard("Ma 25 lat.", "Ma 25 lat.", False))
|
|
|
|
def test_value_check_is_skipped_for_identifiers(self):
|
|
""""LO 3842" is correctly read digit by digit, so demanding num2words'
|
|
rendering of it would reject a good answer."""
|
|
self.assertEqual(
|
|
normalizer._guard("Lot LO 3842 jest opóźniony.",
|
|
"Lot LO trzy osiem cztery dwa jest opóźniony.",
|
|
False), "")
|
|
|
|
|
|
class Fallback(unittest.TestCase):
|
|
"""Degraded path: nominative only, bare integers only. It must still never
|
|
lose a number or raise."""
|
|
|
|
def test_expands_multi_digit(self):
|
|
out = normalizer._fallback("Wygrał 1247 złotych.")
|
|
self.assertNotIn("1247", out)
|
|
self.assertIn("tysiąc", out)
|
|
|
|
def test_keeps_single_digit(self):
|
|
self.assertEqual(normalizer._fallback("Mam 3 koty."), "Mam 3 koty.")
|
|
|
|
def test_large_number_with_separators(self):
|
|
out = normalizer._fallback("populacja 1 350 000 osób")
|
|
self.assertNotIn("350", out)
|
|
self.assertIn("milion", out)
|
|
|
|
def test_leaves_decimals_and_times_alone(self):
|
|
for text in ("Około 3,5 metra.", "Spotkanie o 9:30.", "Radio 95,7 FM."):
|
|
self.assertEqual(normalizer._fallback(text), text)
|
|
|
|
def test_number_followed_by_sentence_punctuation(self):
|
|
"""A comma or full stop ends a number; it does not disqualify it. Real
|
|
subtitles are full of "Przód 85, tył 75." and the brief's boundary set
|
|
skipped every one of them."""
|
|
out = normalizer._fallback("Przód 85, tył 75.")
|
|
self.assertNotIn("85", out)
|
|
self.assertNotIn("75", out)
|
|
self.assertIn("osiemdziesiąt pięć", out)
|
|
self.assertIn("siedemdziesiąt pięć", out)
|
|
|
|
def test_never_raises(self):
|
|
for text in ("", "———", "9" * 400, "1/0", "0000"):
|
|
with self.subTest(text=text[:20]):
|
|
normalizer._fallback(text)
|
|
|
|
|
|
class NormalizeContract(unittest.TestCase):
|
|
"""Properties that hold on both paths — this is what the caller relies on."""
|
|
|
|
def test_no_digits_returns_input_unchanged(self):
|
|
text = "Zwykłe zdanie bez liczb."
|
|
self.assertEqual(normalizer.normalize(text), text)
|
|
|
|
def test_only_single_digits_skips_the_model(self):
|
|
calls = []
|
|
original = normalizer._model_normalize
|
|
normalizer._model_normalize = lambda t: calls.append(t) or t
|
|
try:
|
|
self.assertEqual(normalizer.normalize("Mam 3 koty."), "Mam 3 koty.")
|
|
finally:
|
|
normalizer._model_normalize = original
|
|
self.assertEqual(calls, [], "model must not be called for single digits")
|
|
|
|
def test_block_of_only_single_digits_is_returned_untouched(self):
|
|
"""The cheap half of the brief's idea, kept: nothing to gain from
|
|
sending it, a whole sentence of rewriting to risk."""
|
|
for text in ("Mam 3 koty.", "Zostało 5 minut.", "Tylko 7!"):
|
|
with self.subTest(text=text):
|
|
self.assertEqual(normalizer.normalize(text), text)
|
|
|
|
def test_empty_and_odd_inputs(self):
|
|
self.assertEqual(normalizer.normalize(""), "")
|
|
self.assertEqual(normalizer.normalize(" "), " ")
|
|
|
|
def test_generation_error_falls_back(self):
|
|
original = normalizer._model_normalize
|
|
def boom(_):
|
|
raise RuntimeError("CUDA out of memory")
|
|
normalizer._model_normalize = boom
|
|
try:
|
|
out = normalizer.normalize("Wygrał 1247 złotych.")
|
|
finally:
|
|
normalizer._model_normalize = original
|
|
self.assertNotIn("1247", out)
|
|
self.assertIn("tysiąc", out)
|
|
|
|
|
|
@needs_model
|
|
class Neural(unittest.TestCase):
|
|
"""The acceptance list from the build brief, against the real model."""
|
|
|
|
def test_multi_digit_expanded(self):
|
|
out = normalizer.normalize("Wygrał 1247 złotych.")
|
|
self.assertNotIn("1247", out)
|
|
self.assertIn("tysiąc", out.lower())
|
|
|
|
def test_large_number(self):
|
|
out = normalizer.normalize("Miasto ma populację 1 350 000 osób.")
|
|
self.assertNotIn("350", out)
|
|
self.assertIn("milion", out.lower())
|
|
|
|
def test_mixed_single_and_multi(self):
|
|
"""A block sent for its multi-digit number gets its single digits
|
|
normalized too — that is the point of dropping the placeholders, and
|
|
it is what makes "3 maja" come out as "trzeciego maja"."""
|
|
out = normalizer.normalize("Mam 3 koty i 25 psów.").lower()
|
|
self.assertNotIn("3", out)
|
|
self.assertIn("trzy", out)
|
|
self.assertIn("dwadzieścia pięć", out)
|
|
|
|
def test_leg_is_reported(self):
|
|
self.assertEqual(normalizer.normalize_detail("Mam 3 koty.")[1], "skipped")
|
|
self.assertEqual(normalizer.normalize_detail("Jest dopiero 21.00.")[1],
|
|
"complex")
|
|
|
|
def test_ordinal_date_is_fixed(self):
|
|
"""The reason the placeholders had to go: left as a digit, the TTS
|
|
reads "3 maja" as "trzy maja"."""
|
|
out = normalizer.normalize("Urodził się 3 maja 1791 roku.").lower()
|
|
self.assertIn("trzeciego maja", out)
|
|
|
|
def test_no_digits_leak(self):
|
|
out = normalizer.normalize("Pędzi 300 kilometrów na godzinę.")
|
|
self.assertFalse(DIGITS.search(out))
|
|
|
|
def test_case_declension(self):
|
|
"""The whole reason for a neural normalizer: 'o 25 psach' is locative,
|
|
so the numeral must not come out nominative."""
|
|
out = normalizer.normalize("Mówimy o 25 psach.").lower()
|
|
self.assertNotIn("25", out)
|
|
self.assertNotIn("dwadzieścia pięć psach", out)
|
|
|
|
def test_time(self):
|
|
out = normalizer.normalize("Spotkanie o 14:30.")
|
|
self.assertFalse(DIGITS.search(out))
|
|
|
|
def test_offline(self):
|
|
self.assertEqual(os.environ.get("HF_HUB_OFFLINE"), "1")
|
|
self.assertFalse(DIGITS.search(normalizer.normalize("Ma 99 lat.")))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
print(f"model available: {HAVE_MODEL}", file=sys.stderr)
|
|
unittest.main(verbosity=2)
|