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

254 lines
10 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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):
```bash
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:
```python
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)
```python
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
```python
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.
```python
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.