Add subtitle dictionaries, Polish number normalizer, ComfyUI lifecycle control

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>
This commit is contained in:
2026-08-12 21:55:31 +02:00
co-authored by Claude Opus 5
parent 7e2cee02e5
commit e201aadc3e
21 changed files with 3759 additions and 52 deletions
+251
View File
@@ -0,0 +1,251 @@
#!/usr/bin/env python3
"""Compare normalizer models on the same sentences.
The shipped model is a 0.6B task-specific fine-tune that needs no prompt but
miscounts large numbers and occasionally drops a word. This runs any causal LM
over the same battery so the trade is measurable rather than argued about:
python bench.py # the fine-tune
python bench.py --model models/PLLuM-4B-instruct-2512 --prompted
python bench.py --model … --prompted --save pllum.json
python bench.py --compare fine-tune.json pllum.json
`--prompted` switches from the fine-tune's bare "text in, text out" contract to
an instruction + few-shot prompt, which a general instruct model needs. Digit
tokens are banned from the output either way, and decoding is greedy, so a
difference between two runs is the model and not the sampling.
Automatic flags are cheap heuristics, not a grade: `digits` means an
un-normalized digit survived, `lost` means a word from the input has no
plausible inflected match in the output. Read the text before believing either.
"""
import argparse
import json
import re
import sys
import time
from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
import normalizer as nz # noqa: E402 (reuses its protect/restore and banned list)
SYSTEM_PROMPTED = (
"Jesteś narzędziem do normalizacji tekstu dla syntezatora mowy. "
"Zamieniasz wszystkie liczby zapisane cyframi na słowa, w poprawnej formie "
"gramatycznej wynikającej z kontekstu zdania. Nie zmieniasz, nie dodajesz "
"ani nie usuwasz żadnych innych słów. Zwracasz wyłącznie przekształcone "
"zdanie, bez komentarza."
)
# Few-shot examples chosen to pin down the three things a general model gets
# wrong without them: the case must follow the governing word, the rest of the
# sentence must survive verbatim, and the answer must be the sentence alone.
FEWSHOT = [
("Koncert dla 8250 osób.",
"Koncert dla ośmiu tysięcy dwustu pięćdziesięciu osób."),
("Populacja Polski to 38 000 000 mieszkańców.",
"Populacja Polski to trzydzieści osiem milionów mieszkańców."),
("Mówimy o 25 psach.",
"Mówimy o dwudziestu pięciu psach."),
("Wygrał 1247 złotych.",
"Wygrał tysiąc dwieście czterdzieści siedem złotych."),
]
BATTERY = [
"Sprawa dotyczy 1 350 000 ludzi.",
"Koncert dla 8250 osób.",
"Budżet wynosi 12 500 000 złotych.",
"Populacja Polski to 38 000 000 mieszkańców.",
"Zapłacili 1 500 000 000 dolarów.",
"Zostało 999 999 sztuk.",
"Numer seryjny 1234567.",
"Ponad 100 000 widzów na stadionie.",
"Mówimy o 1 350 000 ludziach.",
"Z 8250 osobami nie da się dyskutować.",
"Firma zatrudnia 2 500 pracowników.",
"Dystans 42 195 metrów.",
"Cena spadła z 15 700 000 do 8 250 000.",
"Było tam 25 psów.",
"Mówimy o 25 psach.",
"Przyjechał z 25 psami.",
"Przyglądał się 25 psom.",
"Nie ma tu 25 psów.",
"Urodził się 3 maja 1791 roku.",
"Wzrost o 16 procent w 2026 roku.",
"Spotkanie o 14:30.",
"Godzina 23:56.",
"Radio 95,7 FM.",
"Około 3,5 metra.",
"Mieszka pod 12/45.",
"Wygrał 1247 złotych.",
"To kosztuje 1999 dolarów i 99 centów.",
"Mam 3 koty i 25 psów.",
"Zostało 5 minut.",
"Temperatura opon w porządku. Przód 85, tył 75.",
"Louis Chiron miał 57 lat, kiedy wygrał Grand Prix Monako.",
"Zwykłe zdanie bez żadnych liczb.",
]
_WORD_RE = re.compile(r'[^\W\d_]+', re.UNICODE)
def lost_words(src: str, out: str) -> list:
"""Input words with no plausible match in the output.
Polish inflects, so an exact comparison is useless — `ludziach` legitimately
becomes `ludziach` and nothing else, but `psach`/`psami` share a stem. A
five-character prefix is a crude stand-in for a lemma and is good enough to
notice a noun that vanished entirely, which is the failure worth catching.
"""
def stems(text):
return {w.lower()[:5] for w in _WORD_RE.findall(text)}
missing = stems(src) - stems(out)
return sorted(w for w in missing if len(w) >= 4)
def load_prompted(model_dir: str, gpu_gib: float = None,
system: str = None, fewshot: list = None):
"""A general instruct model: system prompt, few-shot, greedy, digits banned.
`gpu_gib` caps GPU use and spills the rest to RAM. Needed for the int4
"awq" builds: transformers decompresses compressed-tensors weights back to
bfloat16 at load, so an 11B model wants ~22 GB whatever the file size says.
Offloading is slow but it is the difference between measuring a model and
guessing about it.
"""
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
tok = AutoTokenizer.from_pretrained(model_dir)
kwargs = {"dtype": torch.bfloat16, "device_map": "auto"}
if gpu_gib == 0:
# Everything in RAM. Slow, but the only way to run a compressed-tensors
# int4 build in transformers: its decompression hook expands weights to
# bfloat16 in place, and it cannot read the meta tensors that accelerate
# leaves behind when max_memory forces a partial offload.
kwargs["device_map"] = {"": "cpu"}
elif gpu_gib:
kwargs["max_memory"] = {0: f"{gpu_gib}GiB", "cpu": "80GiB"}
model = AutoModelForCausalLM.from_pretrained(model_dir, **kwargs).eval()
banned = set()
for tid in range(len(tok)):
piece = tok.decode([tid]).strip()
if piece and piece.isdigit():
banned.add(tid)
banned = sorted(banned)
print(f" {len(banned)} digit tokens banned; device "
f"{next(model.parameters()).device}", file=sys.stderr)
def run(text: str) -> str:
messages = [{"role": "system", "content": system or SYSTEM_PROMPTED}]
for user, assistant in (fewshot or FEWSHOT):
messages.append({"role": "user", "content": user})
messages.append({"role": "assistant", "content": assistant})
messages.append({"role": "user", "content": text})
prompt = tok.apply_chat_template(messages, tokenize=False,
add_generation_prompt=True)
inputs = tok(prompt, return_tensors="pt").to(model.device)
n = inputs["input_ids"].shape[1]
with torch.no_grad():
out = model.generate(**inputs, max_new_tokens=nz.MAX_NEW_TOKENS,
do_sample=False,
pad_token_id=tok.pad_token_id or tok.eos_token_id,
suppress_tokens=banned)
answer = tok.decode(out[0][n:], skip_special_tokens=True).strip()
# Instruct models like to introduce themselves; keep the last non-empty
# line, which is the sentence in every observed case.
lines = [l.strip() for l in answer.splitlines() if l.strip()]
return lines[-1] if lines else answer
return run
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--model", default=None,
help="Model directory (default: the shipped fine-tune)")
ap.add_argument("--prompted", action="store_true",
help="Use an instruction + few-shot prompt (general models)")
ap.add_argument("--gpu-gib", type=float, default=None, metavar="N",
help="Cap GPU memory and spill the rest to RAM (slow, but "
"the only way to run a model that does not fit)")
ap.add_argument("--no-protect", action="store_true",
help="Do not shield standalone single digits — normalize the "
"text exactly as the model card does")
ap.add_argument("--save", metavar="FILE", help="Write results as JSON")
ap.add_argument("--compare", nargs=2, metavar=("A", "B"),
help="Print two saved runs side by side and exit")
args = ap.parse_args()
if args.compare:
a, b = (json.loads(Path(f).read_text(encoding="utf-8")) for f in args.compare)
agree = 0
for ra, rb in zip(a["results"], b["results"]):
if ra["out"] == rb["out"]:
agree += 1
continue
print(f" in : {ra['in']}")
print(f" A : {ra['out']}")
print(f" B : {rb['out']}\n")
print(f"identical: {agree}/{len(a['results'])} "
f"A={a['model']} B={b['model']}")
return 0
if args.prompted:
if not args.model:
ap.error("--prompted needs --model")
run = load_prompted(args.model, args.gpu_gib)
label = args.model
else:
if args.model:
nz.MODEL_DIR = Path(args.model)
if not nz.load_model():
print("model unavailable; refusing to benchmark the fallback",
file=sys.stderr)
return 1
run = nz.normalize
label = str(nz.MODEL_DIR)
if args.no_protect:
nz.KEEP_RE = re.compile(r'(?!x)x') # matches nothing
label += " [no single-digit protection]"
results, t0 = [], time.time()
for text in BATTERY:
t = time.time()
out = run(text)
results.append({"in": text, "out": out, "sec": round(time.time() - t, 2),
"digits": bool(nz.DIGIT_RE.search(nz.KEEP_RE.sub("", out))),
"lost": lost_words(text, out)})
total = time.time() - t0
for r in results:
flags = []
if r["digits"]:
flags.append("DIGITS LEFT")
if r["lost"]:
flags.append("LOST: " + ", ".join(r["lost"]))
print(f" in : {r['in']}")
print(f" out: {r['out']}" + (f" [{'; '.join(flags)}]" if flags else ""))
print()
n_dig = sum(r["digits"] for r in results)
n_lost = sum(bool(r["lost"]) for r in results)
print(f"{label}\n {len(results)} sentences in {total:.1f}s "
f"({total/len(results):.2f}s each)\n"
f" {n_dig} with digits left, {n_lost} with a word apparently lost")
if args.save:
Path(args.save).write_text(json.dumps(
{"model": label, "prompted": args.prompted, "seconds": round(total, 1),
"results": results}, ensure_ascii=False, indent=1), encoding="utf-8")
print(f" saved to {args.save}")
return 0
if __name__ == "__main__":
sys.exit(main())