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>
254 lines
10 KiB
Markdown
254 lines
10 KiB
Markdown
# 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.
|