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:
+256
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user