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
+256
View File
@@ -111,6 +111,25 @@ python3 lektor_ai_comfy.py input/film.mkv --resume-from 47
The console prints `[47/693]` style progress, so the number to resume from is
the last one that completed.
**It does not reprocess the subtitles.** The blocks come from the job folder's
existing `tts_input.srt`, so the dictionary and the number normalizer are not
run again:
```
[1/4] Resuming from block 47 — reusing tts_input.srt…
693 blocks, subtitles not reprocessed — the numbering still matches the clips on disk.
```
That is a correctness requirement, not an optimisation. `--resume-from` pairs
block *N* with `clip_NNNN.flac`, and re-deriving the text can change it — a
dictionary rule added since, a different normalizer result, a changed `--start`.
Same block count with different words is the dangerous case, because nothing
would look wrong until you listened.
It also means `--start`/`--end` need not be repeated for the block list, since
the saved file already holds the adjusted timings. If the job folder has no
`tts_input.srt`, the run says so and falls back to parsing the SRT.
### `--skip-tts`
Uses a pre-assembled audio file and goes straight to the mux.
@@ -143,6 +162,14 @@ tts_volume = 1.8
[paths]
output_dir = output
dictionary = dictionary.txt
[normalize]
numbers = yes
python = normalizer/venv/bin/python
llm = normalizer/models/PLLuM-4B-instruct-2512
timeout = 60
free_comfy_vram = yes
[comfy]
comfy_url = http://127.0.0.1:8189
@@ -251,8 +278,237 @@ Typical starting point for dialogue-heavy material: `--orig-volume 0.5
| Flag | Default | Meaning |
|---|---|---|
| `--output-dir DIR` | `output` | Where everything is written |
| `--dictionary FILE` | `dictionary.txt` | Word/phrase replacements, `""` disables |
| `--normalize-numbers` / `--no-normalize-numbers` | on | Spell digits out in Polish |
| `--comfy-url URL` | `http://127.0.0.1:8189` | Backend address |
## Replacement dictionary
`dictionary.txt` next to the script rewrites words and phrases in the subtitles
**before** they reach the TTS engine. It exists for everything the model reads
out wrong on its own: abbreviations, units, symbols, names with an unusual
spelling.
One rule per line:
```
km/h = kilometrów na godzinę
np. = na przykład
m.in. = między innymi
/(\d+)\s*%/ = \1 procent
```
| Rule | Behaviour |
|---|---|
| Matching | **Case-sensitive**, whole words only — `np.` does not match inside `znp.`, and `Joshua` does not match `joshua` |
| Capitals | The replacement goes in exactly as written; nothing is re-cased for you |
| Priority | Longer literals win: `km/h` beats a plain `km` rule whichever order they are written in |
| Chaining | Replacement text is never rescanned, so rules cannot feed each other or loop |
| Regex | A pattern in slashes is a regular expression; `\1 \2 …` refer to its groups |
| Comments | Lines starting with `#` or `;`; `->` and `=>` also work as separators |
| Deleting | An empty replacement removes the phrase |
Each run reports what it loaded and how much it changed:
```
Dictionary: 14 rule(s) from /home/lukas/Programy/lektor_ai/dictionary.txt
6 dictionary replacement(s) applied.
```
The replaced text is what lands in the job folder's `tts_input.srt` — check
there when a rule is not doing what you expect. Because `--regen` and
`--rebuild` work from that file, they do **not** re-apply the dictionary: edit
`tts_input.srt` and `--regen` the affected blocks, or rerun from the SRT.
A relative name is looked for in the current directory first and then next to
the script, so one film can keep its own list without hiding the shared one.
A missing file, a line with no separator and an invalid regex are all warnings,
never fatal.
### The queue dictionary
`dictionary.txt` holds rules that are true of Polish generally. A batch often
needs its own — a character's name the model mangles, a recurring
mistranslation — and those do not belong in the shared file.
The GUI's **Queue dictionary** button opens a plain text editor with the same
format, one rule per line:
```
Joshua = Dżoszua
P12 = pozycja dwunasta
# comments work here too
```
Saving writes `queue_dictionary.txt` into the **first queued video's job
folder**, and every video in the queue is run with it. The dialog reports how
many rules parsed, so a typo shows up when you save rather than as a rule that
silently does nothing during the run. **Clear** empties the editor only —
nothing is written until you press Save.
The two files are **merged into one rule set**, not chained: a replacement made
by one still cannot be rewritten by the other, and longest-literal-wins applies
across both. A run lists them separately:
```
Dictionary: 10 rule(s) from /home/lukas/Programy/lektor_ai/dictionary.txt
Dictionary: 2 rule(s) from output/clips/S02E01/queue_dictionary.txt
3 dictionary replacement(s) applied.
```
On the command line it is `--dictionary-extra FILE`, which works with any file
you like.
## Number normalization
Higgs Audio misreads long digit strings and sometimes emits garbled non-words,
so the **last** subtitle stage rewrites numbers as Polish words:
```
Wygrał 1247 złotych. → Wygrał tysiąc dwieście czterdzieści siedem złotych.
Mówimy o 25 psach. → Mówimy o dwudziestu pięciu psach.
Mam 3 koty. → Mam 3 koty.
```
The case matters — `25 psów` / `o 25 psach` / `z 25 psami` are three different
forms of the same numeral — which is why this is a small fine-tuned model rather
than a lookup table. Standalone single digits are left as digits: the TTS reads
those correctly, and expanding them only makes the clip longer than the subtitle
window it has to fit.
It runs **after** the dictionary, so a rule that turns `16%` into `16 procent`
feeds `szesnaście procent`, and after dialogue splitting, so the numbers are
normalized in the clip that actually says them.
| Flag | Default | Meaning |
|---|---|---|
| `--normalize-numbers` | on | Spell digits out |
| `--no-normalize-numbers` | | Leave digits alone for this run |
or in `settings.ini`:
```ini
[normalize]
numbers = no
```
### Install
The model needs `transformers >= 5`, which must not go into the ComfyUI
environment, so it lives in `normalizer/` with its own venv:
```bash
normalizer/setup.sh # venv + torch + transformers
HF_TOKEN=hf_... normalizer/setup.sh --model # the weights
```
The weights are **gated**: accept the terms at
<https://huggingface.co/Folx/qwen3-0.6b-pl-text-normalization> with your Hugging
Face account, then create a token at <https://huggingface.co/settings/tokens>.
That is the only step that needs the network; runs are offline afterwards.
Full details, including the JSON-lines protocol and the deviations from the
build brief, are in [normalizer/README.md](../normalizer/README.md).
### Two models, and which one gets a block
Numbers fail in two different ways and no single model handles both:
| block contains | goes to | because |
|---|---|---|
| plain integers only (74%) | num2words → inflection model | num2words cannot miscount; the model only fixes the case |
| times, dates, decimals, `P10`, `LO 3842` (26%) | the 0.6B fine-tune | it was trained on those formats; the other leg destroys them |
The split is why `Mówili o 250000 uchodźcach.` comes out as *o dwustu
pięćdziesięciu tysiącach uchodźców* — the fine-tune alone renders that as
*o pięciu tysiącach*, losing the number — while `Teraz na P12.` still comes out
as *P dwanaście* rather than *Pdziesięć*.
Every answer is then checked before use: runaway length, repetition loops,
invalid characters, leftover digits, a lost minus sign, a changed number, a lost
word. A block that fails falls back to num2words — nominative, but never wrong
about the value. The run reports how often that happened.
Set `llm =` (empty) to use the fine-tune for everything.
### What a run prints
```
5 block(s) contain numbers — normalizing…
Asking ComfyUI to unload its models (7.9 GB)…
Freed 7.9 GB.
Normalizer ready (model on cuda:0, two models).
5 of 5 block(s) rewritten (2 complex, 3 plain).
1 block(s) failed a safety check and fell back (word lost x1).
```
`Normalizer ready (fallback)` means the model is missing or failed to load and
`num2words` is standing in — bare integers in the nominative case only, with
times, dates and decimals left as digits. The run says so and continues.
### Sharing the GPU with ComfyUI
Higgs stays resident between runs — about 8.4 GB of a 16 GB card — which leaves
too little for the normalizer. That failure is silent rather than loud:
`accelerate` answers a shortage by spilling layers to RAM, so the run continues
with no error and roughly **5× the time per block** (measured: 1.19 s → 5.96 s).
So before starting the normalizer the script asks ComfyUI to unload:
```
5 block(s) contain numbers — normalizing…
Asking ComfyUI to unload its models (7.9 GB)…
Freed 7.9 GB.
Normalizer ready (model on cuda:0).
```
ComfyUI reloads Higgs on the first clip of step 2 — 20–30 s, once per run. It is
a `POST /free` on ComfyUI's own API, not a restart: the server stays up, with
its custom nodes loaded. The unload is asynchronous, so the script waits until
`torch_vram_total` in `/system_stats` actually drops before continuing.
`--regen` and `--rebuild` work from `tts_input.srt`, which is already
normalized, so they never normalize and never pay the reload. On a freshly
started ComfyUI the call is a no-op and prints nothing.
Set `free_comfy_vram = no` to skip it — worth doing only if you have VRAM to
spare, or if you have turned the neural normalizer off.
Nothing about this stage is fatal. A missing venv, a worker that dies, a block
that takes longer than `timeout` — each is a warning, and the affected text goes
to the TTS with its digits intact. The worker is started once per run and exits
before TTS generation begins, so it is not holding VRAM while Higgs works.
## The backend's lifecycle
ComfyUI is normally started once and left running. The script manages it when it
has to:
| situation | what happens |
|---|---|
| not running when a clip is submitted | started via `start_comfyui.sh`, waited for, the clip resubmitted |
| running but wedged or suspended | stopped (SIGKILL if suspended) before the restart |
| dies on a clip | up to 3 retries, each with a **different seed** |
| dies 3 times on the same clip | that clip is skipped, the queue continues, all skips reported at the end |
| queue finished | models unloaded, backend left running — it reloads on the next request |
| GUI closed | backend stopped, so no idle process is left holding the GPU |
Its output goes to `comfyui.log` next to the script as well as to the terminal
window, so a crash can be diagnosed after the fact.
The GUI has a **ComfyUI** button showing the same state: `Start ComfyUI` when it
is down, `ComfyUI ●` when it is answering, `ComfyUI ◍` when it holds the port but
does not answer. Pressing it starts or restarts.
Quitting the GUI stops the backend, including one you started by hand in a
terminal — it is found by port, not by remembering what was launched. Between
runs it stays up on purpose: the queue only unloads its models, so the next job
does not pay for a cold start.
`start_comfyui.sh` ends with `exec python main.py`, so the script's PID *is*
ComfyUI's. Without that the launcher only ever sees a wrapper shell, and killing
it orphans the python still holding 8 GB of VRAM.
## Output
Everything belonging to one video is kept together in a **job folder** named