Files
lektor-ai/BUILD_BRIEF_pl_number_normalizer.md
claudeandClaude Opus 5 e201aadc3e 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>
2026-08-12 21:55:31 +02:00

10 KiB
Raw Permalink Blame History

Build brief — Polish number normalizer (offline transformers component)

Scope of this document

This brief covers only the normalizer component: installing it, running it offline with plain transformers, and what it does. Integration is out of scope — the calling app (lektor_ai) handles SRT parsing, decides which subtitle blocks to normalize, and invokes this component. The user will specify that wiring separately.

Assume the caller sends one sentence / subtitle block at a time, and only sends blocks that already contain at least one digit. This component's job is to return that text with numbers converted to spoken Polish words, per the rules below.

Purpose

The downstream TTS (Higgs Audio v3) mispronounces raw digits — especially long numbers — sometimes emitting garbled non-words. This component removes that failure by converting digits to fully spelled-out Polish words before the text reaches the TTS, with correct grammatical case (the reason a neural normalizer is used rather than a lookup table: Polish numerals decline by case, gender, and the noun they govern).

Behaviour rules:

  1. Skip single-digit numbers. A standalone one-digit integer (e.g. 3 in Mam 3 koty) is read correctly by the TTS and must be left as a digit.
  2. Expand two-or-more-digit numbers to words (25, 2020, 1 350 000).
  3. Complex numeric expressions — decimals (3,5), times (14:30), dates (3 maja), ratios/addresses (12/45), currency, units — are handled by the model. They are not "single-digit numbers" and are not skipped.
  4. A block that (after removing single digits) contains no multi-digit number is returned unchanged.

The model

  • Repo: Folx/qwen3-0.6b-pl-text-normalization (Hugging Face). ~1.1 GB, bfloat16, 600M params, Qwen3ForCausalLM. Fine-tuned specifically for Polish TTS text normalization (numbers, dates, times, units, abbreviations).
  • GATED REPO. The user must accept the terms on the model page and provide an HF_TOKEN for the one-time download. The build agent cannot accept the licence. Load the model from a local path / local cache at run time — never re-download.
  • CRITICAL version requirement: transformers >= 5.0.0 and torch >= 2.1.0. Older transformers (e.g. 4.57) tokenize the Qwen3 chat template differently and silently produce wrong output even under greedy decode. Pin transformers>=5.0.
  • System prompt (fixed, required): Zamieniasz tekst na formę mówioną.
  • Context: max 512 tokens; quality degrades past ~300 characters. Input is one short block per call, so this is fine — warn (don't truncate) on longer inputs.
  • Decoding: greedy (do_sample=False), with constrained decoding that bans digit tokens from the output so no un-normalized number can leak through.

Environment & installation

Install into a dedicated, isolated venv/conda env, separate from the ComfyUI / Higgs TTS environment. Rationale: this model hard-requires transformers>=5.0, and installing that into the existing working TTS environment risks disturbing its pinned dependencies. Keep them apart; this component is invoked as its own process.

Pinned dependencies:

  • transformers>=5.0.0
  • torch>=2.1.0 — CUDA build matching the host (target: RTX 3090, 24 GB)
  • accelerate — for device_map
  • num2words — used only by the fallback path

One-time model fetch (network + token required):

HF_TOKEN=... huggingface-cli download Folx/qwen3-0.6b-pl-text-normalization \
    --local-dir ./model

Run time is offline. Set HF_HUB_OFFLINE=1 and TRANSFORMERS_OFFLINE=1, and load the model from ./model (or the local HF cache). Add a check that a full run succeeds with networking disabled.

Load the model, tokenizer, and banned-token list once (module level) and reuse across calls — model load dominates cost.

Normalization logic

For a given input string:

  1. Protect single-digit numbers. Replace each standalone one-digit integer with a digit-free sentinel, remembering the mapping. The boundary set excludes adjacent digits and numeric punctuation so decimals/times/dates are not caught:

    KEEP_RE = re.compile(r'(?<![\d.,:/\-])\d(?![\d.,:/\-])')
    

    Sentinels must contain no digits (digit tokens are banned in output, and the check in step 2 looks for leftover digits). Use letter-indexed sentinels, e.g. KEEPNUMA, KEEPNUMB, … .

  2. Decide. If the protected text still contains a digit, that digit belongs to a 2+ digit / complex number → call the model. If no digit remains, skip the model and return the text with sentinels restored (only single digits were present).

  3. Model call. Build the chat prompt (fixed system prompt + protected text), greedy decode with digit tokens banned, decode the completion.

  4. Restore sentinels to their original single digits.

  5. On any generation error, use the fallback instead of raising.

Reference: model wrapper (adapt, then verify against the gated model)

import re, torch
from transformers import AutoModelForCausalLM, AutoTokenizer

MODEL = "./model"  # local, offline
SYS = "Zamieniasz tekst na formę mówioną."

tok = AutoTokenizer.from_pretrained(MODEL)
model = AutoModelForCausalLM.from_pretrained(
    MODEL, torch_dtype=torch.bfloat16, device_map="auto"
).eval()

def _banned_ids(tok):
    banned = set()
    for tid in range(tok.vocab_size):
        d = tok.decode([tid]).strip()
        if d and d.isdigit():
            banned.add(tid)
    for sym in ["$","%","€","£","¥","°","§","@","#","&","×","÷","±",
                "©","®","™","→","←","↑","↓","√","∞","≈","≠","≤","≥"]:
        ids = tok.encode(sym, add_special_tokens=False)
        if len(ids) == 1:
            banned.add(ids[0])
    return sorted(banned)

BANNED = _banned_ids(tok)

def _model_normalize(text: str) -> str:
    messages = [{"role": "system", "content": SYS},
                {"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=400, do_sample=False,
                             pad_token_id=tok.pad_token_id,
                             suppress_tokens=BANNED)
    return tok.decode(out[0][n:], skip_special_tokens=True).strip()

Reference: skip / decide / restore + public function

KEEP_RE = re.compile(r'(?<![\d.,:/\-])\d(?![\d.,:/\-])')
_LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"

def _sentinel(i: int) -> str:
    s = ""; i += 1
    while i:
        i, r = divmod(i - 1, 26); s = _LETTERS[r] + s
    return f"KEEPNUM{s}"

def normalize(text: str) -> str:
    """Return `text` with 2+ digit numbers spelled out in Polish; single-digit
    numbers left as digits. Safe to call on any string."""
    mapping = {}
    def protect(m):
        key = _sentinel(len(mapping)); mapping[key] = m.group(0); return key
    protected = KEEP_RE.sub(protect, text)

    if re.search(r'\d', protected):
        try:
            result = _model_normalize(protected)
        except Exception:
            result = _fallback(protected)
    else:
        result = protected

    for key, digit in mapping.items():
        result = result.replace(key, digit)
    return result

Reference: fallback (model unavailable / generation error)

Guarantees the component never raises. Degraded: nominative case only, bare multi-digit integers only (leaves decimals/times/dates untouched rather than mangle them). Log a warning when used.

from num2words import num2words

_INT_RE = re.compile(r'(?<![\d.,:/\-])\d{2,}(?:[\u00a0 .]\d{3})*(?![\d.,:/\-])')

def _fallback(text: str) -> str:
    def repl(m):
        digits = re.sub(r'[\u00a0 .]', '', m.group(0))
        return num2words(int(digits), lang="pl")
    return _INT_RE.sub(repl, text)

Error handling

  • Input longer than ~300 chars: log a warning (quality drops), still process, never truncate silently.
  • Model load failure: fall back to num2words and log clearly that the neural normalizer is unavailable — never crash.
  • Per-call generation error: use the fallback for that call only.

Testing / acceptance

The model produces valid case variants, so assert on properties, not exact strings, except where noted.

  • Single digit preserved: Mam 3 koty. → still contains 3.
  • Multi-digit expanded: Wygrał 1247 złotych. → no 1247; contains tysiąc.
  • Large number: populacja 1 350 000 osób → no digits from that number; contains milion.
  • Mixed (key sentinel round-trip): Mam 3 koty i 25 psów. → keeps 3, expands 25 → dwadzieścia pięć.
  • Only single digits: model is not called; text returned unchanged (assert via a spy on _model_normalize).
  • No digits leak: expanded output contains no digit characters except restored single digits.
  • Sentinel round-trip: KEEPNUM… never appears in final output. If the model is found to alter/inflect the sentinel, flag it — the fix is a more inert sentinel, or dropping protection and documenting that single digits co-occurring with multi-digit numbers get expanded.
  • Offline: the suite passes with networking disabled, HF_HUB_OFFLINE=1, TRANSFORMERS_OFFLINE=1.

Deliverables

  • normalizer.py — model/tokenizer/banned-list loaded once at import; public normalize(text: str) -> str; _fallback; skip/decide/restore logic.
  • requirements.txt — pinned as above.
  • README.md — env creation, one-time gated download with HF_TOKEN, offline run variables. (No caller/integration details — those are provided separately.)
  • tests/ — the checks above with sample inputs.

Design decisions (defaults — confirm if you disagree)

  1. Single digit inside a date/time. 3 maja keeps its 3 (space-separated standalone single digit), while 9:30, 3,5, 12/45 go to the model and are expanded. If you want 3 maja → trzeciego maja, that's a one-line change to the boundary set.
  2. Sentinel robustness. Small residual risk the model alters the placeholder instead of passing it through. Encoded as an explicit test with a documented fallback so it's caught, not shipped silently.