Initial commit: Polish AI dubbing pipeline (ComfyUI + Higgs Audio V3)

Converts SRT subtitles into a Polish lektor-style voiceover using a local
ComfyUI instance running Higgs Audio V3, then muxes the result back into the
source video as a second audio track. Video and original audio are always
stream-copied; only the new voiceover track is encoded.

Contents:
  lektor_ai_comfy.py    main pipeline (standard library only, needs ffmpeg)
  lektor_ai_comfy_v2.py batched-context experiment (timing unresolved)
  lektor_ai.py          earlier version using the hosted Boson AI API
  install_comfyui.sh    reproduces the ComfyUI environment, versions pinned
  docs/                 requirements, installation, usage, architecture,
                        troubleshooting
  gudowski/             voice references (.wav) with their transcripts (.txt)

Source media and generated video are gitignored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-09 14:29:53 +02:00
co-authored by Claude Opus 5
commit fc4b761f52
23 changed files with 4452 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
# Media — source films and generated video. Tens of GB, and not ours to publish.
input/
output/
# Python
__pycache__/
*.py[cod]
*.egg-info/
.venv/
venv/
# Machine-local Claude Code permissions (intentionally not shared)
.claude/settings.local.json
# Editor / OS noise
.vscode/
.idea/
*.swp
.DS_Store
# Scratch
*.log
*.tmp
_batch_tmp/
+57
View File
@@ -0,0 +1,57 @@
=============================================================================
NOTE: These are the original planning notes. Use install_comfyui.sh instead —
it reproduces what is ACTUALLY installed, with versions pinned.
Two things below do not match the real installation:
* "cu128 for your 3090" — the machine has an RTX 4070 Ti SUPER (16 GB).
The cu128 wheels are still correct; only the comment is wrong.
* The `conda install -c conda-forge portaudio libsamplerate ffmpeg espeak-ng
git` line was never actually applied. The environment contains only the 27
base Python packages from conda-forge. ffmpeg is used from the system
(/usr/bin/ffmpeg) and espeak-ng is not installed at all. Everything works
without them, so install_comfyui.sh does not install them either — it only
warns if ffmpeg is missing, since lektor_ai_comfy.py needs it (ComfyUI
itself does not).
=============================================================================
Option A — Conda/Mamba (cleanest match for "a virtual environment, no system changes")
Miniforge gives you its own Python and the native libs (portaudio, ffmpeg, espeak-ng…) inside the environment prefix, so there's no deadsnakes, no sudo apt, nothing in /usr.
bash
# Install Miniforge into its own dir — fully self-contained, removable
wget https://github.com/conda-forge/miniforge/releases/latest/download/Miniforge3-Linux-x86_64.sh
bash Miniforge3-Linux-x86_64.sh -b -p ~/miniforge3
# Activate WITHOUT running `conda init` so it doesn't even touch your .bashrc:
source ~/miniforge3/etc/profile.d/conda.sh
conda create -n comfy-tts python=3.12 -y
conda activate comfy-tts
# The libs that were apt before — now inside the env, no sudo:
conda install -c conda-forge portaudio libsamplerate ffmpeg espeak-ng git -y
# PyTorch via pip wheels (more current than conda's channel), cu128 for your 3090:
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu128
Then ComfyUI + the suite, all inside the env:
bash
mkdir -p ~/comfy-tts-app && cd ~/comfy-tts-app
git clone https://github.com/comfyanonymous/ComfyUI.git
cd ComfyUI && pip install -r requirements.txt
cd custom_nodes
git clone https://github.com/diodiogod/TTS-Audio-Suite.git
cd TTS-Audio-Suite && python install.py
Launch:
bash
source ~/miniforge3/etc/profile.d/conda.sh
conda activate comfy-tts
cd ~/comfy-tts-app/ComfyUI && python main.py --port 8189
To remove all of it: conda env remove -n comfy-tts, delete ~/comfy-tts-app, and if you want it truly gone, rm -rf ~/miniforge3. Your system Python and apt are never touched.
+232
View File
@@ -0,0 +1,232 @@
absl-py==2.4.0
accelerate==1.14.0
aiohappyeyeballs==2.6.2
aiohttp==3.14.1
aiosignal==1.4.0
alembic==1.18.5
aliyun-python-sdk-core==2.16.0
aliyun-python-sdk-kms==2.16.5
annotated-doc==0.0.4
annotated-types==0.7.0
antlr4-python3-runtime==4.9.3
anyio==4.14.1
attrs==26.1.0
audioread==3.1.0
av==17.1.0
babel==2.18.0
bitsandbytes==0.49.2
blake3==1.0.9
braceexpand==0.1.7
certifi==2026.6.17
cffi==2.0.0
charset-normalizer==3.4.7
click==8.4.2
cn2an==0.5.24
comfy-aimdo==0.4.10
comfy-kitchen==0.2.12
comfyui-embedded-docs==0.5.5
comfyui-workflow-templates-core==0.3.263
comfyui-workflow-templates-media-api==0.3.81
comfyui-workflow-templates-media-image==0.3.157
comfyui-workflow-templates-media-other==0.3.226
comfyui-workflow-templates-media-video==0.3.98
comfyui_frontend_package==1.45.19
comfyui_workflow_templates==0.10.7
conformer==0.3.2
crcmod==1.7
cryptography==49.0.0
csvw==4.0.0
cuda-bindings==12.9.4
cuda-pathfinder==1.2.2
cuda-toolkit==12.8.1
Cython==3.2.6
dacite==1.9.2
datasets==5.0.0
decorator==5.3.1
dill==0.4.1
Distance==0.1.3
dlinfo==2.0.0
dyNET38==2.2
echo-tts==0.1.4
editdistance==0.8.1
einops==0.8.2
einx==0.4.3
ema-pytorch==0.7.9
encodec==0.1.1
faiss-cpu==1.14.3
filelock==3.29.0
flatbuffers==25.12.19
frozendict==2.4.7
frozenlist==1.8.0
fsspec==2026.4.0
funasr==1.3.14
g2p-en==2.1.0
gitdb==4.0.12
GitPython==3.1.50
glfw==2.10.0
greenlet==3.5.2
h11==0.16.0
h5py==3.16.0
hf-xet==1.5.1
httpcore==1.0.9
httpx==0.28.1
huggingface_hub==1.21.0
hydra-core==1.3.3
hyper-connections==0.4.11
HyperPyYAML==1.2.3
idna==3.18
inflect==7.5.0
isodate==0.7.2
jaconv==0.5.0
jamo==0.4.1
jieba==0.42.1
Jinja2==3.1.6
jmespath==0.10.0
joblib==1.5.3
json5==0.15.0
jsonschema==4.26.0
jsonschema-specifications==2025.9.1
kaldiio==2.18.1
keras==3.15.0
kornia==0.8.3
kornia_rs==0.1.14
langcodes==3.5.1
language-tags==1.3.1
lazy-loader==0.5
librosa==0.11.0
lingua-language-detector==2.2.0
llvmlite==0.47.0
local-attention==1.11.2
loguru==0.7.3
Mako==1.3.12
markdown-it-py==4.2.0
MarkupSafe==3.0.3
mdurl==0.1.2
ml_dtypes==0.5.4
modelscope==1.37.1
monotonic-alignment-search==0.2.1
more-itertools==11.1.0
mpmath==1.3.0
msgpack==1.2.1
multidict==6.7.1
multiprocess==0.70.19
munch==4.0.0
nagisa==0.2.12
namex==0.1.0
narwhals==2.22.1
networkx==3.6.1
ninja==1.13.0
nltk==3.9.4
numba==0.65.1
numpy==2.2.6
nvidia-cublas-cu12==12.8.4.1
nvidia-cuda-cupti-cu12==12.8.90
nvidia-cuda-nvrtc-cu12==12.8.93
nvidia-cuda-runtime-cu12==12.8.90
nvidia-cudnn-cu12==9.19.0.56
nvidia-cufft-cu12==11.3.3.83
nvidia-cufile-cu12==1.13.1.3
nvidia-curand-cu12==10.3.9.90
nvidia-cusolver-cu12==11.7.3.90
nvidia-cusparse-cu12==12.5.8.93
nvidia-cusparselt-cu12==0.7.1
nvidia-nccl-cu12==2.28.9
nvidia-nvjitlink-cu12==12.8.93
nvidia-nvshmem-cu12==3.4.5
nvidia-nvtx-cu12==12.8.90
omegaconf==2.3.1
onnxruntime==1.27.0
openai-whisper==20250625
optree==0.19.1
orjson==3.11.9
oss2==2.19.1
packaging @ file:///home/conda/feedstock_root/build_artifacts/bld/rattler-build_packaging_1777103621/work
pandas==3.0.3
peft==0.19.1
phonemizer==3.3.0
pillow==12.2.0
platformdirs==4.10.0
pooch==1.9.0
praat-parselmouth==0.4.7
proces==0.1.7
propcache==0.5.2
protobuf==7.35.1
psutil==7.2.2
punctuators==0.0.7
pyarrow==24.0.0
pycparser==3.0
pycryptodome==3.23.0
pydantic==2.13.4
pydantic-settings==2.14.2
pydantic_core==2.46.4
pydub==0.25.1
Pygments==2.20.0
pynndescent==0.6.0
PyOpenGL==3.1.10
pyparsing==3.3.2
pyphen==0.17.2
pypinyin==0.55.0
python-dateutil==2.9.0.post0
python-dotenv==1.2.2
pyworld==0.3.5
PyYAML==6.0.3
rdflib==7.6.0
referencing==0.37.0
regex==2026.5.9
requests==2.34.2
rfc3986==1.5.0
rich==15.0.0
rpds-py==2026.5.1
ruamel.yaml==0.18.17
ruamel.yaml.clib==0.2.15
safetensors==0.8.0
scikit-learn==1.9.0
scipy==1.18.0
segments==2.4.0
sentencepiece==0.2.1
sentry-sdk==2.63.0
setuptools==70.2.0
shellingham==1.5.4
simpleeval==1.0.7
six==1.17.0
smmap==5.0.3
sounddevice==0.5.5
soundfile==0.14.0
soxr==1.1.0
spandrel==0.4.2
SQLAlchemy==2.0.51
sympy==1.14.0
tensorboardX==2.6.5
termcolor==3.3.0
textstat==0.7.13
threadpoolctl==3.6.0
tiktoken==0.13.0
tokenizers==0.22.2
torch==2.11.0+cu128
torch-complex==0.4.4
torch-einops-utils==0.1.4
torchaudio==2.11.0+cu128
torchcodec==0.14.0
torchdiffeq==0.2.5
torchfcpe==0.0.4
torchsde==0.2.6
torchvision==0.26.0+cu128
tqdm==4.68.3
trampoline==0.1.2
transformers==5.12.1
triton==3.6.0
typeguard==4.5.2
typer==0.25.1
typing-inspection==0.4.2
typing_extensions==4.15.0
umap-learn==0.5.12
Unidecode==1.4.0
uritemplate==4.2.0
urllib3==2.7.0
vocos==0.1.0
wandb==0.28.0
webdataset==1.0.2
wheel==0.47.0
x-transformers==2.23.1
xxhash==3.7.1
yarl==1.24.2
+66
View File
@@ -0,0 +1,66 @@
# lektor_ai_comfy — Documentation
Converts SRT subtitles into a Polish AI voiceover in the *lektor* style (a single
narrator read over the quietened original soundtrack) using a local ComfyUI
instance running the Higgs Audio V3 TTS model, then muxes the result back into
the video as a second audio track.
The source video is never re-encoded. Video and the original audio are
stream-copied; only the new voiceover track is encoded.
## Documentation map
| Document | What it covers |
|---|---|
| [requirements.md](requirements.md) | Hardware, software, and the exact dependency surface |
| [installation.md](installation.md) | Installing ComfyUI + the TTS engine, and verifying it |
| [usage.md](usage.md) | Every CLI flag, the working modes, and practical recipes |
| [architecture.md](architecture.md) | How the pipeline works internally, end to end |
| [troubleshooting.md](troubleshooting.md) | Diagnosed failure modes and their fixes |
## Quick start
Assuming ComfyUI is already installed (if not, see
[installation.md](installation.md)):
```bash
# 1. Start the TTS backend and leave it running
~/comfy-tts-app/start_comfyui.sh
# 2. In another terminal, generate the voiceover
cd ~/Programy/lektor_ai
python3 lektor_ai_comfy.py "input/film.mkv" --embedded-subs
```
The result is `output/film_lektor.mkv` with two audio tracks — **Original** and
**Polish** — plus every subtitle track from the source.
## The two halves of this project
The pieces are deliberately decoupled and it helps to keep them apart when
diagnosing anything:
**ComfyUI + Higgs Audio V3** — a local HTTP service on port 8189 that turns text
into speech. Heavy: ~22 GB on disk, needs an NVIDIA GPU, lives in its own conda
environment. It knows nothing about subtitles or video.
**`lektor_ai_comfy.py`** — a single-file script that parses subtitles, drives
ComfyUI over HTTP, and does all timing and audio/video assembly with ffmpeg. It
is pure Python standard library, runs on the system Python, and needs no conda
environment or pip packages of its own.
So a TTS problem (bad pronunciation, hallucination, mumbling) belongs to the
first half, and a timing, mixing, or muxing problem belongs to the second.
## Conventions in these docs
Paths are written as they exist on the machine this was developed on:
```
~/Programy/lektor_ai the project
~/comfy-tts-app/ComfyUI the TTS backend
~/miniforge3 the conda distribution
```
If yours differ, update the constants at the top of `lektor_ai_comfy.py` — see
[installation.md](installation.md#configuring-the-script).
+215
View File
@@ -0,0 +1,215 @@
# Architecture
How `lektor_ai_comfy.py` turns subtitles into a finished video, and why each
stage works the way it does. Most of the non-obvious choices here exist because
the straightforward version was tried first and broke.
```
SRT / embedded track
│ parse, clean, split dialogue
▼
subtitle blocks ──────────────► output/tts_input.srt
│ one HTTP job per block
▼
ComfyUI + Higgs Audio V3
│ per-clip atempo + fade
▼
clips/clip_NNNN.flac
│ adelay + amix, batched
▼
tts_assembled.flac
│ pass 1: mix with original audio
│ pass 2: stream-copy mux
▼
{stem}_lektor.mkv
```
## 1. Subtitle parsing
`parse_subtitle_blocks()` reads the SRT with `utf-8-sig` (tolerating a BOM),
splits on blank lines, and keeps blocks whose start falls inside the requested
range. Times become **relative to `--start`**, which is why a later `--rebuild`
must repeat the same `--start`/`--end`.
`_clean_text()` then normalises each block, in this order:
1. **Strip formatting tags.** `{...}` ASS overrides (`{\an8}`, `{\pos(x,y)}`)
and `<...>` HTML tags (`<i>`, `<font …>`).
2. **Detect dialogue.** Lines beginning `- ` mean two speakers and are kept
separate; ordinary wrapped lines are joined with a space.
3. **Sentence-case ALL-CAPS blocks**, which the model would otherwise shout.
4. **Capitalise a lowercase first letter**, since a fragment continuing from the
previous subtitle confuses the model.
5. **Add terminal punctuation** so the model produces a natural falling tail.
Step 1 must come first. `<i>ALL CAPS</i>` looks mixed-case while the tag is
still present — the `i` in `<i>` is a lowercase letter — so the caps detection
in step 3 would silently miss it.
`split_dialogue_blocks()` then divides a two-speaker block into one clip per
line, apportioning the block's duration by character count.
The result is written to `output/tts_input.srt`. That file is the source of
truth for block numbering: block *N* there is `clips/clip_NNNN.flac` and the
argument to `--regen N`.
## 2. Speech generation
For each block the script POSTs a workflow graph to `/prompt`, polls
`/history/{id}` every 3 s, and reads the resulting file directly from ComfyUI's
output directory.
The graph has five nodes:
| Node | Class | Role |
|---|---|---|
| 1 | `HiggsAudioV3EngineNode` | Model, temperature, top_p 0.95, top_k 50 |
| 2 | `LoadAudio` | Voice reference WAV from ComfyUI's input dir |
| 3 | `CharacterVoicesNode` | Pairs that audio with its transcript |
| 4 | `UnifiedTTSTextNode` | The text, the seed, `enable_audio_cache: False` |
| 5 | `SaveAudio` | Writes FLAC |
Three details matter:
- **`enable_audio_cache: False`** is essential. With caching on, ComfyUI returns
a previous result for repeated text and every `--regen` is a no-op.
- **The seed is `base_seed + block_index`**, so a fixed `--seed` is fully
reproducible while each clip still differs.
- **No language tag is sent.** Higgs Audio V3 detects Polish from the text
itself; its tokenizer has no `<|lang:pl|>` token, and a `[pl:]` prefix is
stripped by the character parser before it reaches the model. A `lang: en`
line in the ComfyUI log is cosmetic.
Each finished clip is optionally head-trimmed (`--strip-leading-ms`), then
sped up with `atempo` and given a short fade-out — **per clip, before
assembly**. Applying `atempo` to the assembled track instead would speed up the
silences between clips and shift every subsequent timestamp.
## 3. Placement
Clips are positioned with a push-forward cursor:
```python
actual_start = max(t_start, cursor)
cursor = actual_start + duration
```
A clip starts at its subtitle time, or when the previous clip finished if that
is later. Clips are **never trimmed** to their subtitle window — cutting speech
mid-sentence sounds far worse than running a little long. Overrun cascades
forward and then resolves by itself at the next natural gap in the subtitles,
because a gap lets the cursor fall back below the next `t_start`.
The console prints `[+1.20s behind SRT]` when drift occurs. Steady growth means
`--speed` is too low for the material.
## 4. Assembly
Each clip becomes an `adelay` filter input and everything is mixed with `amix`:
```
[0:a]adelay=1340:all=1[a0]; [1:a]adelay=4820:all=1[a1]; …
[a0][a1]…amix=inputs=N:duration=longest:normalize=0[out]
```
`normalize=0` is important — `amix` otherwise divides by the input count, which
would make every clip inaudibly quiet across hundreds of inputs.
Above 500 clips the work is **batched**, because one ffmpeg process cannot hold
thousands of open inputs. Each batch is assembled to its own temporary FLAC and
the batches are then mixed together.
Batches use **relative** delays: the first clip's start time is subtracted from
every clip in the batch, and re-applied when the batches are combined. Without
that subtraction, batch 7 would begin with 40 minutes of silence, wasting time
and making the progress bar useless.
## 5. Muxing
`merge_voiceover()` runs **two separate ffmpeg passes**. This is deliberate and
should not be collapsed back into one.
### Pass 1 — build the lektor mix
```
[0:a:0]<norm>,aformat=channel_layouts=stereo,volume=<orig>[orig_mix];
[1:a]aformat=channel_layouts=stereo,volume=<tts>[tts];
[orig_mix][tts]amix=inputs=2:duration=first:normalize=0[lektor]
```
Written to a temporary `*_mix.m4a`, AAC 192k, and deleted in a `finally` block.
**Stereo, not 5.1.** ffmpeg's AAC encoder does not signal a 5.1 channel layout —
the track comes back tagged `unknown` in every container tested, and players
that cannot route the channels play silence. Stereo is tagged correctly, plays
everywhere, and spends the 192 kbit/s on two channels instead of six. Track 0
keeps the untouched 5.1, so no surround information is lost.
**PTS normalisation.** MKV rips often start their audio at a non-zero PTS (one
source here begins at 4.043 s). `amix` cannot emit output before both inputs
have frames, so the voiceover would be delayed by that offset. When ffprobe
reports an offset the filter becomes:
```
asetpts=PTS-STARTPTS,adelay=<offset_ms>:all=1,aresample=async=1
```
`asetpts` rebases the stream to zero and `adelay` reinserts the offset as real
silence, so the background audio stays where it belongs while `amix` receives
frames from both inputs starting at zero.
`duration=first` with `orig_mix` listed first means the mix runs as long as the
film, not as long as the voiceover.
### Pass 2 — stream-copy mux
Video, original audio, the mix, and every subtitle track are copied into the
final MKV with `-c copy`. Nothing is re-encoded, and it runs at several hundred
times real time.
Two flags carry the weight here:
**`-max_interleave_delta 0`**, placed **after the maps and before the output
path**. It is an output-muxer option; before `-i` it silently configures the
demuxer and does nothing. Without it, sparse subtitle tracks make the muxer
abandon interleaving after its default 10-second window, and the lektor audio
ends up written hundreds of megabytes away from the video it belongs with —
measurably fine to ffmpeg, but silent in any player that reads sequentially.
**MKV output**, not MP4. MKV copies subtitles of any codec without conversion,
carries DTS and TrueHD in track 0, and avoids the `mov_text` conversion that
made large multi-subtitle files abort mid-mux.
### Why two passes
Doing the filtering and the subtitle muxing in one command fails on sources with
many subtitle tracks. The sparse subtitle streams stall the muxer while the
audio filter graph keeps producing, and ffmpeg aborts partway through with:
```
Error while filtering: Invalid argument
Failed to inject frame into filter network: Invalid argument
```
The message points at audio, but audio is not the cause. Splitting the work
keeps the filter graph free of subtitle streams and makes the final mux a pure
copy. It is also about ten times faster.
## Progress reporting
`_run_progress()` adds `-progress pipe:1 -nostats` before the output path, reads
`out_time_us=` lines from stdout, and rewrites a single line with `\r`. A daemon
thread drains stderr concurrently, so ffmpeg cannot deadlock on a full stderr
pipe while the main thread reads stdout.
## Design rules
Worth preserving, since each was learned by breaking it:
1. **Per-clip processing before assembly**, never on the assembled track.
2. **Never trim clips** to the subtitle window.
3. **Keep every clip file** — `--regen` depends on them.
4. **FLAC throughout**, encoding only the final voiceover track.
5. **Two audio tracks**, with the original stream-copied and untouched.
6. **Verify muxed output by byte position**, not only by volume. A track can
measure perfectly and still be unplayable.
+207
View File
@@ -0,0 +1,207 @@
# Installation
Two things need to exist: the **ComfyUI backend** (large, isolated, GPU-bound)
and the **script** (which needs nothing installed at all).
## Automated install
`install_comfyui.sh` in the project root reproduces the exact working
environment. It is idempotent — safe to re-run, and it skips any step already
satisfied.
```bash
cd ~/Programy/lektor_ai
./install_comfyui.sh
```
Roughly 30–60 minutes, mostly downloading. Expect ~22 GB.
### Options
| Flag | Effect |
|---|---|
| *(none)* | Pinned versions — ComfyUI `7cb784e0`, suite `v5.3.0`. Recommended. |
| `--latest` | Newest ComfyUI and suite instead. You are on your own for breakage. |
| `--skip-model` | Skip the 8.7 GB download; it happens on first generation anyway. |
| `--lock FILE` | Restore an exact package set from a `pip freeze` lockfile. |
| `--help` | Usage summary. |
### Changing where things go
```bash
CONDA_ROOT=/opt/miniforge3 COMFY_ROOT=/data/comfy ./install_comfyui.sh
```
| Variable | Default |
|---|---|
| `CONDA_ROOT` | `~/miniforge3` |
| `COMFY_ROOT` | `~/comfy-tts-app` |
| `ENV_NAME` | `comfy-tts` |
| `COMFY_PORT` | `8189` |
| `PYTHON_VERSION` | `3.12` |
If you change `COMFY_ROOT` or `COMFY_PORT`, update the script constants too —
see [Configuring the script](#configuring-the-script).
### Exact reproduction
`comfyui-requirements.lock.txt` pins all 232 packages. To rebuild a
byte-identical environment:
```bash
./install_comfyui.sh --lock comfyui-requirements.lock.txt
```
Regenerate it after intentional changes:
```bash
source ~/miniforge3/etc/profile.d/conda.sh && conda activate comfy-tts
pip freeze > comfyui-requirements.lock.txt
```
## What the installer does
Useful to know when something fails midway, since each step can be repeated on
its own.
1. **Preflight** — checks Linux, git, a downloader, GPU, disk space, ffmpeg.
Only missing git or a downloader is fatal; the rest warn.
2. **Miniforge** → `~/miniforge3`, batch mode. `conda init` is deliberately not
run, so your `.bashrc` is never touched.
3. **Conda env** `comfy-tts` with Python 3.12, then a guard that refuses to
continue if the wrong Python somehow ended up active.
4. **PyTorch** from the cu128 index, pinned to 2.11.0.
5. **ComfyUI** cloned and checked out at the pinned commit (detached HEAD — this
is expected, not an error), then its `requirements.txt` installed.
6. **TTS-Audio-Suite** cloned, checked out at `v5.3.0`, then **`python
install.py`** — its own resolver, which must not be replaced with a plain
pip install (see [requirements.md](requirements.md#dependency-surface)).
7. **Model** — `bosonai/higgs-audio-v3-tts-4b` into
`ComfyUI/models/TTS/higgs_audio_v3/`.
8. **Launcher** written to `$COMFY_ROOT/start_comfyui.sh`.
9. **Verification** — imports torch, torchaudio, transformers, safetensors,
soundfile, librosa and reports CUDA availability.
## Manual install
The equivalent by hand, if you would rather not use the script:
```bash
# Miniforge, self-contained, no shell integration
wget https://github.com/conda-forge/miniforge/releases/latest/download/Miniforge3-Linux-x86_64.sh
bash Miniforge3-Linux-x86_64.sh -b -p ~/miniforge3
source ~/miniforge3/etc/profile.d/conda.sh
conda create -n comfy-tts python=3.12 -y
conda activate comfy-tts
# PyTorch with the CUDA 12.8 runtime bundled
pip install torch==2.11.0 torchvision==0.26.0 torchaudio==2.11.0 \
--index-url https://download.pytorch.org/whl/cu128
# ComfyUI, pinned
mkdir -p ~/comfy-tts-app && cd ~/comfy-tts-app
git clone https://github.com/comfyanonymous/ComfyUI.git
cd ComfyUI && git checkout 7cb784e0f48784bb6ed588912e186e5ee1e9ee68
pip install -r requirements.txt
# The TTS node, pinned — note install.py, NOT requirements.txt
cd custom_nodes
git clone https://github.com/diodiogod/TTS-Audio-Suite.git
cd TTS-Audio-Suite && git checkout tags/v5.3.0
python install.py
```
The model downloads itself on first generation. To fetch it ahead of time:
```python
from huggingface_hub import snapshot_download
snapshot_download(
repo_id="bosonai/higgs-audio-v3-tts-4b",
local_dir="~/comfy-tts-app/ComfyUI/models/TTS/higgs_audio_v3/higgs-audio-v3-tts-4b",
)
```
## Running the backend
```bash
~/comfy-tts-app/start_comfyui.sh
```
which is just:
```bash
#!/bin/bash
source ~/miniforge3/etc/profile.d/conda.sh
conda activate comfy-tts
cd ~/comfy-tts-app/ComfyUI && python main.py --port 8189
```
Leave it running in its own terminal for the whole session — model load takes
tens of seconds and would otherwise be repeated. The web UI at
<http://127.0.0.1:8189> is useful for watching progress and reading errors, but
is not needed; the script drives everything over HTTP.
## Configuring the script
`lektor_ai_comfy.py` has four constants at the top. If you installed anywhere
other than the defaults, edit them:
```python
COMFY_URL = "http://127.0.0.1:8189"
COMFY_OUTPUT_DIR = Path("/home/lukas/comfy-tts-app/ComfyUI/output")
COMFY_INPUT_DIR = Path("/home/lukas/comfy-tts-app/ComfyUI/input")
VOICE_PROMPTS_DIR = Path("/home/lukas/Programy/lektor_ai/gudowski")
```
`COMFY_URL` can also be overridden per-run with `--comfy-url`. The two directory
constants cannot — the script reads generated audio directly from ComfyUI's
output directory and copies voice references into its input directory, so both
must be real paths on the same filesystem.
The script itself needs no installation. Ensure ffmpeg is present:
```bash
ffmpeg -version && ffprobe -version
```
If missing, and you would rather not use sudo:
```bash
conda install -n comfy-tts -c conda-forge ffmpeg
```
Note that this puts ffmpeg inside the conda env, so the script must then be run
with that env active.
## Verifying
```bash
# 1. Backend responds
curl -s http://127.0.0.1:8189/system_stats | head -c 200
# 2. GPU visible to PyTorch
source ~/miniforge3/etc/profile.d/conda.sh && conda activate comfy-tts
python -c "import torch; print(torch.__version__, torch.cuda.is_available())"
# -> 2.11.0+cu128 True
# 3. Model present
ls -la ~/comfy-tts-app/ComfyUI/models/TTS/higgs_audio_v3/higgs-audio-v3-tts-4b/model.safetensors
# 4. End-to-end on 60 seconds of a real file
cd ~/Programy/lektor_ai
python3 lektor_ai_comfy.py "input/film.mkv" --embedded-subs --end 01:00
```
The short run is the real test — it exercises subtitle parsing, TTS, assembly
and muxing, and finishes in a couple of minutes.
## Uninstalling
Nothing was installed system-wide, so removal is just deleting directories:
```bash
conda env remove -n comfy-tts
rm -rf ~/comfy-tts-app
rm -rf ~/miniforge3 # only if nothing else uses it
```
+132
View File
@@ -0,0 +1,132 @@
# Requirements
## Hardware
| | Minimum | Notes |
|---|---|---|
| GPU | NVIDIA, 12 GB VRAM | Developed on an RTX 4070 Ti SUPER (16 GB) |
| Driver | 525+ | Needed by the CUDA 12.8 wheels |
| Disk | ~35 GB free | ~22 GB installed, plus working room for output |
| RAM | 16 GB | The final mux briefly peaks around 2.6 GB |
The CUDA **toolkit** is not required. The PyTorch cu128 wheels bundle their own
CUDA runtime, so the system `nvcc` version is irrelevant — this machine has CUDA
11.5 installed system-wide while PyTorch runs cu128 perfectly well. Only the
driver version matters.
CPU-only inference technically works but is slow enough to be impractical for a
feature-length film.
### Disk footprint
```
~/miniforge3/envs/comfy-tts 9.7 GB conda env (PyTorch and ~233 packages)
~/comfy-tts-app/ComfyUI 3.2 GB application code and dependencies
└── models/TTS 8.8 GB Higgs Audio V3 weights
------
21.7 GB
```
Output is roughly the size of the source video, since video is stream-copied. A
60-minute 10 Mbit/s episode produces about 5 GB. Intermediate FLAC clips add a
few hundred MB per film and are kept deliberately, so individual clips can be
regenerated without redoing a whole run (see
[usage.md](usage.md#working-modes)).
## Software
| Component | Version here | Notes |
|---|---|---|
| OS | Linux (Ubuntu 22.04) | Only Linux is supported by the installer |
| ComfyUI | 0.26.0 (`7cb784e0`) | Pinned |
| TTS-Audio-Suite | v5.3.0 (`09df1681`) | The only custom node installed |
| Higgs Audio V3 | `bosonai/higgs-audio-v3-tts-4b` | 8.7 GB, auto-downloaded |
| Python (ComfyUI) | 3.12.13 | conda-forge, in the `comfy-tts` env |
| PyTorch | 2.11.0+cu128 | With torchvision 0.26.0, torchaudio 2.11.0 |
| Python (script) | 3.8+ | System Python; 3.10.12 here |
| ffmpeg / ffprobe | 4.4+ | System install; 4.4.2 here |
## Dependency surface
This is the point most worth internalising, because it makes debugging much
simpler: **the two halves have almost nothing in common.**
### `lektor_ai_comfy.py` — standard library only
```python
argparse json random re shutil subprocess sys threading time uuid
pathlib.Path urllib.request
```
No third-party packages. No conda environment. No virtualenv. It runs on the
system Python:
```bash
/usr/bin/python3 lektor_ai_comfy.py --help # works, nothing installed
```
Its only external requirements are **ffmpeg** and **ffprobe** on `PATH`, and a
reachable ComfyUI instance. It talks to ComfyUI over plain HTTP with
`urllib.request` — there is no ComfyUI client library involved.
> **`requirements.txt` in the project root does not apply here.** It belongs to
> the older `lektor_ai.py`, which used the hosted Boson AI API and needed
> `openai>=1.0.0`. `lektor_ai_comfy.py` requires nothing from it.
### ComfyUI — a large, isolated environment
233 pip packages in the `comfy-tts` conda env. The important ones:
```
torch 2.11.0+cu128 torchaudio 2.11.0+cu128 torchvision 0.26.0+cu128
transformers 5.12.1 tokenizers 0.22.2 accelerate 1.14.0
numpy 2.2.6 scipy 1.18.0 librosa 0.11.0
soundfile 0.14.0 safetensors 0.8.0 einops 0.8.2
```
The conda environment itself holds only the 27 base conda-forge packages that
make up the Python runtime. Everything else arrives via pip. In particular,
`portaudio`, `libsamplerate`, `ffmpeg` and `espeak-ng` are **not** installed in
the environment despite appearing in the original planning notes — the system
ffmpeg is used and espeak-ng is absent entirely. Everything works without them.
TTS-Audio-Suite installs its dependencies through its own `install.py`, not
through its `requirements.txt`. That file deliberately omits packages needing
special handling — `librosa`, `descript-audio-codec`, `onnxruntime`,
`opencv-python` and others are installed with `--no-deps` because pip would
otherwise drag numpy backwards and break the rest of the environment. Never
substitute `pip install -r requirements.txt` for `python install.py`.
## Input requirements
**Video** — anything ffmpeg can demux. MKV and MP4 are the usual cases. The
container must support what gets muxed back in; output is always MKV, which
accepts every codec involved.
**Subtitles** — either an external `.srt`, or a text-based track inside the
video (`subrip`, `ass`, `ssa`, `webvtt`, `mov_text`). Image-based tracks
(`hdmv_pgs_subtitle` from Blu-ray, `dvd_subtitle`) contain bitmaps rather than
text and cannot drive TTS without OCR; the `--embedded-subs` picker marks them
as unusable. They are still copied to the output untouched.
**Voice reference** — a `.wav` plus a `.txt` holding its exact transcript, both
in `gudowski/`. Three are bundled:
| Voice | Length | Format |
|---|---|---|
| `gudowski` | 2:10 | 44.1 kHz stereo |
| `gudowski_short` (default) | 0:52 | 44.1 kHz stereo |
| `gudowski_short_2` | 0:28 | 44.1 kHz stereo |
The transcript matters as much as the audio — voice cloning quality drops
noticeably without an accurate one. The script warns if the `.txt` is missing.
## Network
Needed only at install time, for the ComfyUI and TTS-Audio-Suite clones, the pip
downloads, and the 8.7 GB model. Generation afterwards is entirely local; no
API keys, no accounts, no per-use cost.
The model is published under a research / non-commercial licence and may require
accepting its terms on huggingface.co, then `hf auth login`, before it will
download.
+295
View File
@@ -0,0 +1,295 @@
# Troubleshooting
Failure modes that have actually occurred here, with the diagnosis that
identified each one. Where a symptom is misleading, that is called out — several
of these point somewhere other than their cause.
## Audio problems in the finished file
### The lektor track is silent in the player
The single most misleading failure in this project. It has **two distinct
causes**, and volume measurements cannot tell them apart — both produce a track
that measures perfectly loud.
Diagnose by checking the declared channel layout and the interleaving, in that
order.
**Cause 1 — unknown channel layout**
```bash
ffprobe -v error -select_streams a -show_entries stream=index,codec_name,channels,channel_layout -of csv=p=0 out.mkv
```
```
1,eac3,6,5.1(side) ← original, fine
2,aac,6,unknown ← broken: 6 channels, no routing information
```
ffmpeg's AAC encoder does not signal a 5.1 layout, in any container. Players
cannot map the channels and output silence. The fix is the stereo lektor track
now used by default (`aformat=channel_layouts=stereo` on both inputs). A correct
track reports `2,stereo`.
**Cause 2 — broken interleaving**
Compare byte positions for the same timestamp:
```bash
ffprobe -read_intervals 00:20:00%+1 -select_streams v -show_entries packet=pos -of csv=p=0 out.mkv | head -1
ffprobe -read_intervals 00:20:00%+1 -select_streams a:1 -show_entries packet=pos -of csv=p=0 out.mkv | head -1
```
They should be within a few MB. Observed on a broken file:
| time | drift from video |
|---|---|
| 02:00 | 477 MB |
| 10:00 | **2315 MB** |
| 50:00 | 740 MB |
Sparse subtitle tracks make the muxer give up interleaving after its default
10-second window. ffmpeg reads the file fine, so every level measurement looks
healthy — but players read MKV sequentially and never reach that audio.
The fix is `-max_interleave_delta 0` as an **output** option. An existing file
can be repaired without regenerating anything:
```bash
ffmpeg -i broken.mkv -map 0 -c copy -max_interleave_delta 0 fixed.mkv
```
14 seconds and ~2.6 GB of transient RAM for a 60-minute file.
> Placement is the trap. Before `-i` the flag configures the demuxer, changes
> nothing, and produces byte-identical output — which is easily mistaken for
> "this fix doesn't work".
### The voiceover is late by a constant offset
The source audio starts at a non-zero PTS. Confirm:
```bash
ffprobe -v error -select_streams a:0 -show_entries stream=start_time -of csv=p=0 film.mkv
# 4.043000 ← not 0
```
`amix` cannot emit output until both inputs have frames, so the voiceover is
delayed by that amount. Handled automatically now
(`asetpts=PTS-STARTPTS,adelay=<offset>`); the run prints:
```
Audio PTS offset: 4.043s — normalising orig_mix for lektor track
```
Note that this affects **only** the mixed track. Track 0 is stream-copied and
was never wrong, so "the original audio is offset" and "the original audio
inside the lektor mix is offset" are different problems with different fixes.
### Voiceover drifts progressively out of sync
Not a mixing fault — clips are overrunning their windows and pushing each other
forward. The console shows it:
```
→ placed at 00:12:03,400, duration 3.10s [+2.40s behind SRT]
```
Raise `--speed` (try 1.3–1.4) and rebuild. Small drift is expected and
self-corrects at the next gap in the subtitles; only sustained growth is a
problem.
### The whole voiceover sounds too quiet or too loud
Adjust and rebuild — no regeneration needed:
```bash
python3 lektor_ai_comfy.py film.mkv --rebuild --orig-volume 0.5 --tts-volume 2.0
```
Track 0 is unaffected by these, always.
## Muxing failures
### `Failed to inject frame into filter network: Invalid argument`
```
Error while filtering: Invalid argument
Failed to inject frame into filter network: Invalid argument
Error while processing the decoded data for stream #0:1
```
Aborts partway through, at a **different timestamp each run**. The message
blames audio; audio is not the cause. It appears when filtering and
subtitle muxing happen in one ffmpeg command on a source with many subtitle
tracks — the sparse streams stall the muxer while the filter graph keeps
producing.
The varying failure point is the tell. A genuinely corrupt frame fails at the
same place every time.
Fixed by the two-pass structure in `merge_voiceover()`. If you are editing that
function, do not merge the passes back together.
### The mux fails on DTS or TrueHD audio
Only an issue if output is changed to MP4, which cannot carry those codecs. The
default MKV output handles them. Keep it.
## TTS problems
### A clip recites the reference transcript
The model occasionally speaks the contents of `gudowski.txt` instead of the
subtitle. Most likely at `--temperature 1.0`; `0.9` is the default for this
reason.
```bash
python3 lektor_ai_comfy.py film.mkv --regen 88
python3 lektor_ai_comfy.py film.mkv --rebuild
```
`--seed 0` randomises each run, so a regeneration gets a different sample.
### A clip is cut off mid-word
The token ceiling was reached. Raise it and regenerate just that clip:
```bash
python3 lektor_ai_comfy.py film.mkv --regen 214 --max-tokens 8192
```
### Every clip returns identical audio
`enable_audio_cache` is being set to `True` somewhere in
`build_text_workflow()`. It must stay `False` or ComfyUI serves cached results
and `--regen` does nothing.
### Stiff pauses at every comma
Baked into the model weights. There is no SSML support and no parameter for it.
Rephrasing the subtitle text is the only lever.
### Audible breath at the start of clips
```bash
python3 lektor_ai_comfy.py film.mkv --regen 5 --strip-leading-ms 120
```
Values of 80–150 ms usually suffice. Too high clips the first consonant.
### Voice cloning quality is poor
Check that the reference `.txt` exists and matches the `.wav` exactly. The run
prints:
```
Reference text loaded (930 chars)
```
or warns if it is missing. An inaccurate transcript degrades cloning noticeably.
## Subtitle problems
### Formatting tags are being spoken
`<i>`, `</i>` and `{\an8}` are stripped by `_strip_sub_tags()` before anything
else. If new tags appear, extend that function — and keep the stripping first,
because tags interfere with the ALL-CAPS detection that follows.
### ALL-CAPS text is being shouted
Handled by `_clean_text()`, but only after tags are removed. `<i>HELLO</i>`
looked mixed-case while the tag was present, because `i` is a lowercase letter.
If shouting persists, check for an unhandled tag type.
### `--embedded-subs` shows no usable tracks
The video only carries image-based subtitles (`hdmv_pgs_subtitle`,
`dvd_subtitle`). These are bitmaps and need OCR. Supply an external `.srt`
instead — the image tracks are still copied to the output.
### Wrong language selected
The picker lists language tags but they are only as good as the source's
metadata. `output/embedded_sub.srt` holds exactly what was extracted; check it
before committing to a full run.
## Backend problems
### `Connection refused` on port 8189
ComfyUI is not running. Start it and wait for the model to load:
```bash
~/comfy-tts-app/start_comfyui.sh
curl -s http://127.0.0.1:8189/system_stats | head -c 200
```
### ComfyUI crashed and the GPU is stuck
```bash
sudo fuser -k /dev/nvidia*
sudo nvidia-smi --gpu-reset
```
Then restart and resume from the last completed block:
```bash
python3 lektor_ai_comfy.py film.mkv --resume-from 312
```
### Out of VRAM
Nothing else should be using the GPU during a run. Restart ComfyUI to clear
fragmentation. The 4B model needs roughly 10–12 GB.
### `torch.cuda.is_available()` is False
```bash
source ~/miniforge3/etc/profile.d/conda.sh && conda activate comfy-tts
python -c "import torch; print(torch.__version__, torch.cuda.is_available())"
```
Expect `2.11.0+cu128 True`. If False, check `nvidia-smi` works and the driver is
525 or newer. The CUDA **toolkit** version is irrelevant — the wheels bundle
their own runtime, and this machine runs cu128 wheels against a system CUDA
11.5 without issue.
## Script problems
### `Provide an SRT subtitle file, or use --embedded-subs`
A mode that needs subtitles was given only a video. Either pass the SRT first,
or add `--embedded-subs`.
### `No such file or directory` on the video
Almost always an unquoted path with spaces or parentheses, or a wrong extension
(`.mp4` where the file is `.mkv`). Quote it and check with `ls`.
### `Missing clip files — regenerate them first`
`--rebuild` needs a complete set. The listed clips must be regenerated with
`--regen`, or `--resume-from` used to fill the gap.
### `No tts_input.srt found`
`--rebuild` and `--regen` require a previous full run in the same
`--output-dir`.
## General diagnostic method
The lesson from the silent-track investigation, which took several wrong turns:
1. **Measure, don't assume.** Level, layout, and byte position each reveal a
different class of fault.
2. **Isolate by subtraction.** Removing subtitles from the failing command is
what proved they were the cause, not the audio the error named.
3. **A varying failure point means state, not data.** Corrupt input fails in the
same place every time; buffering and interleaving problems move around.
4. **"ffmpeg reads it fine" is not "a player can play it."** Verify position, not
just content.
5. **Check flag placement.** Several ffmpeg options mean different things before
`-i` versus before the output path, and the wrong position often fails
silently rather than erroring.
+266
View File
@@ -0,0 +1,266 @@
# Usage
ComfyUI must be running before any command that generates speech
(`~/comfy-tts-app/start_comfyui.sh`). `--rebuild` is the exception — it only
touches ffmpeg and works with the backend stopped.
## Invocation
```
python3 lektor_ai_comfy.py [SRT] VIDEO [options]
```
Both positionals are optional in the argparse sense, because several modes do
not need an SRT file. The rule:
```bash
# External subtitle file — pass both, SRT first
python3 lektor_ai_comfy.py input/film.pl.srt input/film.mkv
# Subtitles inside the video — pass the video alone
python3 lektor_ai_comfy.py input/film.mkv --embedded-subs
# --rebuild and --regen also need only the video
python3 lektor_ai_comfy.py input/film.mkv --rebuild
```
When a single positional is given in a mode that needs no SRT, the script moves
it into the video slot automatically. Always quote paths containing spaces or
parentheses:
```bash
python3 lektor_ai_comfy.py "input/Star City (2026)/S01_E01 The Eyes.mkv" --embedded-subs
```
## Working modes
### Full generation (default)
Parses subtitles, generates one TTS clip per block, assembles, muxes.
```bash
python3 lektor_ai_comfy.py input/film.mkv --embedded-subs
```
Roughly real-time-ish on a 4070 Ti SUPER: a 60-minute episode of ~700 subtitle
blocks takes on the order of an hour. Each clip is saved as it completes, so a
crash never loses more than the clip in flight.
### `--embedded-subs`
Lists the video's subtitle tracks and prompts for one:
```
Available subtitle tracks in S01_E01 The Eyes.mkv:
[1] Stream #2: subrip [eng] — Forced
[2] Stream #3: subrip [eng]
[3] Stream #4: subrip [eng] — SDH
...
[30] Stream #31: subrip [pol]
Select track number: 30
```
The chosen track is extracted to `output/embedded_sub.srt` and then processed
exactly like an external file. Image-based tracks are listed but marked
unusable — they hold bitmaps, not text.
### `--rebuild`
Reassembles audio from existing clips and rebuilds the video, with no TTS at
all. This is the fast iteration loop — seconds to minutes rather than an hour.
```bash
python3 lektor_ai_comfy.py input/film.mkv --rebuild
python3 lektor_ai_comfy.py input/film.mkv --rebuild --orig-volume 0.5 --tts-volume 2.0
```
Use it after `--regen`, or to adjust the mix. It requires `output/tts_input.srt`
and a complete set of `output/clips/clip_NNNN.flac`, and reports which are
missing if any are.
If the original run used `--end`, pass it again so the same video fragment is
used.
### `--regen`
Regenerates specific clips by block number, leaving everything else alone.
```bash
python3 lektor_ai_comfy.py input/film.mkv --regen 12 22 41
python3 lektor_ai_comfy.py input/film.mkv --rebuild # then apply
```
Block numbers match `output/tts_input.srt` and the `clip_NNNN.flac` filenames.
Only the selected FLAC files are replaced — `--rebuild` afterwards is what
actually updates the video.
This is the intended fix for a bad clip. There is no automatic retry; when the
model hallucinates, mumbles, or truncates, note the number and regenerate it. A
different seed usually resolves it, and `--seed 0` (the default) randomises
every run.
### `--resume-from`
Continues a crashed run, loading clips `1..N-1` from disk and generating from
`N` onward.
```bash
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.
### `--skip-tts`
Uses a pre-assembled audio file and goes straight to the mux.
```bash
python3 lektor_ai_comfy.py input/film.mkv --skip-tts output/tts_assembled.flac
```
## Options
### Range
| Flag | Default | Meaning |
|---|---|---|
| `--start MM:SS` | `00:00` | Start of the range to process |
| `--end MM:SS` | *(end)* | End of the range |
With `--end`, a matching fragment is extracted to `output/video_fragment.mkv`
and all TTS timestamps become relative to `--start`. Pass the same values to any
later `--rebuild`, or the audio will not line up.
`--end 01:00` is the quickest way to sanity-check a new voice or speed setting.
### Voice and model
| Flag | Default | Meaning |
|---|---|---|
| `--voice NAME\|PATH` | `gudowski_short` | Name in `gudowski/`, or a path to a WAV |
| `--seed N` | `0` | Base seed; `0` randomises each run |
| `--temperature T` | `0.9` | Sampling temperature |
| `--max-tokens N` | `4096` | Token ceiling per clip |
`--temperature` is worth understanding. At `1.0` the model sometimes recites the
reference transcript instead of the subtitle. `0.9` largely prevents that;
lower is more deterministic but flatter. A per-clip seed of `base_seed + index`
means a fixed `--seed` reproduces a run exactly.
Raise `--max-tokens` only if long clips are cut off mid-word.
### Timing and cleanup
| Flag | Default | Meaning |
|---|---|---|
| `--speed FACTOR` | `1.2` | Per-clip speed-up via `atempo` |
| `--strip-leading-ms MS` | `0` | Trim the start of each clip |
| `--trailing-fade-ms MS` | `80` | Fade the end of each clip |
`--speed` compresses speech so a clip is more likely to fit its subtitle window.
It is applied **per clip, before assembly** — never to the assembled track,
which would shift every timestamp by speeding up the silences too.
`--strip-leading-ms` removes an inhale artifact at clip starts; try 80–150 if
you hear one. `--trailing-fade-ms` suppresses tail pops; `0` disables it.
### Mixing
| Flag | Default | Meaning |
|---|---|---|
| `--orig-volume 0-1` | `0.60` | Original audio level inside the lektor mix |
| `--tts-volume 0-2` | `1.8` | Voiceover level inside the lektor mix |
These affect **Track 1 only**. Track 0 is always the untouched original, so
nothing is lost by mixing aggressively. Changing them needs only `--rebuild`.
Typical starting point for dialogue-heavy material: `--orig-volume 0.5
--tts-volume 2.0`. For quiet sources, `--orig-volume 1.0 --tts-volume 1.4`.
### Paths
| Flag | Default | Meaning |
|---|---|---|
| `--output-dir DIR` | `output` | Where everything is written |
| `--comfy-url URL` | `http://127.0.0.1:8189` | Backend address |
## Output
```
output/
tts_input.srt processed text actually sent to TTS — inspect this
embedded_sub.srt extracted embedded subtitle (--embedded-subs only)
clips/clip_NNNN.flac one lossless clip per block, kept for --regen
tts_assembled.flac all clips at their final positions, lossless
video_fragment.mkv extracted segment (--end only)
{stem}_lektor.mkv the finished video
```
The finished file contains:
| Track | Content |
|---|---|
| Video | Stream-copied, untouched |
| Audio 0 | **Original** — stream-copied, full 5.1 if the source had it |
| Audio 1 | **Polish** — lektor mix, AAC 192k stereo, `language=pol` |
| Subtitles | Every track from the source, stream-copied |
The lektor track is stereo deliberately; see
[troubleshooting.md](troubleshooting.md#the-lektor-track-is-silent-in-the-player).
FLAC is used for every intermediate so repeated processing stays lossless. Only
the final voiceover track is ever encoded.
## Recipes
**Check a voice before committing to a full run**
```bash
python3 lektor_ai_comfy.py input/film.mkv --embedded-subs --end 02:00 --voice gudowski
```
**Tune the mix without regenerating speech**
```bash
python3 lektor_ai_comfy.py input/film.mkv --rebuild --orig-volume 0.45 --tts-volume 2.1
```
**Fix a handful of bad clips**
```bash
# read output/tts_input.srt, note the offending block numbers
python3 lektor_ai_comfy.py input/film.mkv --regen 88 91 104
python3 lektor_ai_comfy.py input/film.mkv --rebuild
```
**Reproduce a run exactly**
```bash
python3 lektor_ai_comfy.py input/film.mkv --embedded-subs --seed 123456
```
**Speech running long and overlapping the next line**
```bash
python3 lektor_ai_comfy.py input/film.mkv --embedded-subs --speed 1.35
```
## Reading the console
```
[1/4] Parsing subtitles 00:00–end…
693 entries after dialogue splitting.
[2/4] Generating TTS (693 clips, speed 1.2×)…
[12/693] 00:01:34,120 'Nie wiem, o czym mówisz.'
Waiting for a3f8b201… done.
→ placed at 00:01:34,120, duration 1.84s
```
`→ placed at` reveals timing drift. A clip that overruns its window pushes the
next one forward, shown as `[+1.20s behind SRT]`. Small values are normal and
self-correct at the next natural gap in the subtitles; values that keep growing
mean `--speed` is too low for the material.
Assembly then shows a progress bar per batch (clips are assembled 500 at a time
to stay under the open-file limit), followed by the two-pass mux.
+2
View File
@@ -0,0 +1,2 @@
14 listopada 1917 roku było już wiadomo, że hrabia Adam Tarnowski nie zostanie premierem Rady Regencyjnej. Niemcy nie wyraziły zgody. Żadnego powodu nie podano. Nie, bo nie. Według polskich polityków Tarnowski był idealnym kandydatem na premiera. Wszechstronnie wykształcony, władał biegle kilkoma językami, znał świat. Od 1897 roku pracował w służbie dyplomatycznej Austro-Węgier w Waszyngtonie, Paryżu, Dreźnie, Brukseli, Madrycie i Londynie. W roku 1911 został mianowany ambasadorem Austro-Węgier w Sofii. Sprawował dyplomatyczną misję w trudnym okresie, w czasie wojen bałkańskich i na początku Wielkiej Wojny. W roku 1916 prosto z Sofii udał się do Stanów Zjednoczonych, by objąć funkcję ambasadora Austro-Węgier w tym kraju. Nie otrzymał jednak od Aliantów dyplomatycznego listu żelaznego. Podróżował zatem do Ameryki okrężną drogą. Zjawił się w Waszyngtonie dopiero pod koniec stycznia 1917 roku, kiedy Niemcy zaczynały swoją nieograniczoną wojnę podwodną na Atlantyku. Tarnowski tłumaczył amerykańskiemu sekretarzowi stanu Robertowi Lansingowi, że Austro-Węgry nie mają nic wspólnego z atakowaniem i zatapianiem przez okręty podwodne statków pasażerskich i handlowych. Kiedy okazało się, że prawda jest inna, Tarnowski wyjaśniał amerykańskim politykom, że to Niemcy zmusiły swego sojusznika do uprawiania tego procederu. Nic to Tarnowskiemu nie pomogło. Nie został ambasadorem Stanach Zjednoczonych i wrócił do Wiednia. Wtedy próbował robić karierę w Królestwie Polskim, ale nie został ani członkiem Rady Regencyjnej, ani premierem. Nie życzył sobie tego sam cesarz Wilhelm II. Dla Niemców Tarnowski był nie tylko Polakiem, polskim patriotą, ale przede wszystkim zaufanym człowiekiem Wiednia. A Niemcy i Austriacy, chociaż ciągle sojusznicy, coraz częściej chadzali różnymi drogami. Ofiarą tych sojuszniczych nieporozumień padł właśnie hrabia Adam Tarnowski, który w wolnej Polsce też nie zrobił dyplomatycznej kariery. Może był człowiekiem zupełnie innej epoki.
Binary file not shown.
+2
View File
@@ -0,0 +1,2 @@
Sprawował dyplomatyczną misję w trudnym okresie, w czasie wojen bałkańskich i na początku Wielkiej Wojny. W roku tysiąc dziewięćset szesnastym prosto z Sofii udał się do Stanów Zjednoczonych, by objąć funkcję ambasadora Austro-Węgier w tym kraju. Nie otrzymał jednak od aliantów dyplomatycznego listu żelaznego. Podróżował zatem do Ameryki okrężną drogą. Zjawił się w Waszyngtonie dopiero pod koniec stycznia tysiąc dziewięćset siedemnastego roku, kiedy Niemcy zaczynały swoją nieograniczoną wojnę podwodną na Atlantyku. Tarnowski tłumaczył amerykańskiemu sekretarzowi stanu Robertowi Lansingowi, że Austro-Węgry nie mają nic wspólnego z atakowaniem i zatapianiem przez okręty podwodne statków pasażerskich i handlowych. Kiedy okazało się, że prawda jest inna, Tarnowski wyjaśniał amerykańskim politykom, że to Niemcy zmusiły swojego sojusznika do uprawiania tego procederu.
Binary file not shown.
+2
View File
@@ -0,0 +1,2 @@
Nie życzył sobie tego sam cesarz Wilhelm II. Dla Niemców Tarnowski był nie tylko Polakiem, polskim patriotą, ale przede wszystkim zaufanym człowiekiem Wiednia. A Niemcy i Austriacy, chociaż ciągle sojusznicy, coraz częściej chadzali różnymi drogami. Ofiarą tych sojuszniczych nieporozumień padł właśnie hrabia Adam Tarnowski, który w wolnej Polsce też nie zrobił dyplomatycznej kariery. Może był człowiekiem zupełnie innej epoki.
Binary file not shown.
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env python3
"""Inspect voice picker and Text mode on AI Studio TTS page."""
import json, time
from pathlib import Path
from playwright.sync_api import sync_playwright
profile_dir = Path.home() / ".config" / "lektor_ai" / "browser_profile"
def dump_elements(page, label):
elements = page.evaluate("""() => {
const sel = 'input, textarea, select, [role="combobox"], [role="listbox"], [role="textbox"], [role="option"], button, [contenteditable]';
return [...document.querySelectorAll(sel)].map(el => ({
tag: el.tagName,
role: el.getAttribute('role') || '',
ariaLabel: el.getAttribute('aria-label') || '',
placeholder: el.getAttribute('placeholder') || '',
class: el.className?.toString().slice(0, 100) || '',
text: (el.innerText || el.value || '').slice(0, 80),
}));
}""")
path = f"output/elements_{label}.json"
Path(path).write_text(json.dumps(elements, indent=2, ensure_ascii=False))
print(f"[{label}] {len(elements)} elements → {path}")
with sync_playwright() as pw:
ctx = pw.chromium.launch_persistent_context(
str(profile_dir),
channel="chrome",
headless=False,
viewport={"width": 1400, "height": 900},
ignore_default_args=["--enable-automation"],
args=["--disable-blink-features=AutomationControlled", "--start-maximized"],
)
page = ctx.new_page()
page.goto("https://aistudio.google.com/generate-speech", wait_until="networkidle", timeout=60_000)
time.sleep(3)
# Open editor
page.locator('.text-input-container').first.click()
time.sleep(2)
# Step 1: Click Text mode (the other toggle)
page.locator('button[role="radio"]').filter(has_text="Text").click()
time.sleep(2)
page.screenshot(path="output/shot_3_text_mode.png", full_page=True)
dump_elements(page, "3_text_mode")
# Step 2: Back to Composer, click voice chip
page.locator('button[role="radio"]').filter(has_text="Composer").click()
time.sleep(2)
page.locator('button.voice-chip').first.click()
time.sleep(2)
page.screenshot(path="output/shot_4_voice_picker.png", full_page=True)
dump_elements(page, "4_voice_picker")
input("Press Enter to close...")
ctx.close()
+328
View File
@@ -0,0 +1,328 @@
#!/usr/bin/env bash
#
# install_comfyui.sh — reproduce the ComfyUI + Higgs Audio V3 TTS environment
# used by lektor_ai_comfy.py, on this or any other Linux machine.
#
# Everything lands in two removable directories and nothing touches the system:
# ~/miniforge3 — conda distribution (its own Python)
# ~/comfy-tts-app — ComfyUI, the TTS-Audio-Suite node, and the models
#
# No sudo, no apt, no changes to system Python. `conda init` is never run, so
# your .bashrc is left alone.
#
# Usage:
# ./install_comfyui.sh # pinned versions (recommended)
# ./install_comfyui.sh --latest # newest ComfyUI + suite instead
# ./install_comfyui.sh --skip-model # don't pre-download the 8.7 GB model
# ./install_comfyui.sh --lock FILE # restore exact package set from a lockfile
# ./install_comfyui.sh --help
#
# Override locations with environment variables:
# CONDA_ROOT=/opt/miniforge3 COMFY_ROOT=/data/comfy ./install_comfyui.sh
#
set -euo pipefail
# ── Configuration ────────────────────────────────────────────────────────────
CONDA_ROOT="${CONDA_ROOT:-$HOME/miniforge3}"
COMFY_ROOT="${COMFY_ROOT:-$HOME/comfy-tts-app}"
ENV_NAME="${ENV_NAME:-comfy-tts}"
COMFY_PORT="${COMFY_PORT:-8189}"
PYTHON_VERSION="${PYTHON_VERSION:-3.12}"
# Exact versions of the working installation this script reproduces.
COMFYUI_REPO="https://github.com/comfyanonymous/ComfyUI.git"
COMFYUI_COMMIT="7cb784e0f48784bb6ed588912e186e5ee1e9ee68" # v0.26.0-13-g7cb784e0
TTS_SUITE_REPO="https://github.com/diodiogod/TTS-Audio-Suite.git"
TTS_SUITE_TAG="v5.3.0" # 09df1681
MINIFORGE_URL="https://github.com/conda-forge/miniforge/releases/latest/download/Miniforge3-Linux-x86_64.sh"
# PyTorch: CUDA 12.8 wheels. These carry their own CUDA runtime, so the
# system CUDA toolkit version is irrelevant — only the NVIDIA driver matters.
TORCH_INDEX="https://download.pytorch.org/whl/cu128"
TORCH_PINS=(torch==2.11.0 torchvision==0.26.0 torchaudio==2.11.0)
# The TTS model. The suite downloads this automatically on first use; doing it
# here just means the first generation isn't a surprise 8.7 GB wait.
HF_MODEL_REPO="bosonai/higgs-audio-v3-tts-4b"
MODEL_SUBDIR="models/TTS/higgs_audio_v3/higgs-audio-v3-tts-4b"
REQUIRED_DISK_GB=35 # ~10 env + ~3 ComfyUI + ~9 model + headroom
# ── Options ──────────────────────────────────────────────────────────────────
USE_LATEST=0
SKIP_MODEL=0
LOCKFILE=""
while [[ $# -gt 0 ]]; do
case "$1" in
--latest) USE_LATEST=1; shift ;;
--skip-model) SKIP_MODEL=1; shift ;;
--lock) LOCKFILE="${2:-}"; shift 2 ;;
-h|--help) awk 'NR>1 {if (/^#/) {sub(/^# ?/,""); print} else exit}' "$0"; exit 0 ;;
*) echo "Unknown option: $1 (try --help)" >&2; exit 1 ;;
esac
done
# ── Output helpers ───────────────────────────────────────────────────────────
if [[ -t 1 ]]; then
B=$'\033[1m'; G=$'\033[32m'; Y=$'\033[33m'; R=$'\033[31m'; N=$'\033[0m'
else
B=""; G=""; Y=""; R=""; N=""
fi
step() { echo; echo "${B}==> $*${N}"; }
ok() { echo " ${G}[+]${N} $*"; }
warn() { echo " ${Y}[!]${N} $*"; }
die() { echo " ${R}[X]${N} $*" >&2; exit 1; }
have() { command -v "$1" >/dev/null 2>&1; }
# Activate the conda env. conda's shell functions dislike `set -u`, so it is
# lifted for the duration of the call only.
activate_env() {
# shellcheck disable=SC1091
source "$CONDA_ROOT/etc/profile.d/conda.sh"
set +u
conda activate "$ENV_NAME"
set -u
}
ENV_PY="$CONDA_ROOT/envs/$ENV_NAME/bin/python"
pip_install() { "$ENV_PY" -m pip install --no-input "$@"; }
# ── 0. Preflight ─────────────────────────────────────────────────────────────
step "Preflight checks"
[[ "$(uname -s)" == "Linux" ]] || die "This script targets Linux (found $(uname -s))."
have git || die "git is required. Install it first (e.g. sudo apt install git)."
if have curl; then DL=(curl -fL -o); elif have wget; then DL=(wget -O); else
die "Need curl or wget to download Miniforge."
fi
ok "git and a downloader are present"
if have nvidia-smi && nvidia-smi >/dev/null 2>&1; then
ok "GPU: $(nvidia-smi --query-gpu=name,driver_version,memory.total --format=csv,noheader | head -1)"
else
warn "No usable nvidia-smi. Install will proceed but TTS will run on CPU (very slow)."
warn "The cu128 wheels need an NVIDIA driver >= 525; the CUDA toolkit is NOT required."
fi
mkdir -p "$(dirname "$COMFY_ROOT")"
avail_gb=$(df -BG --output=avail "$(dirname "$COMFY_ROOT")" | tail -1 | tr -dc '0-9')
if (( avail_gb < REQUIRED_DISK_GB )); then
warn "Only ${avail_gb} GB free where $COMFY_ROOT will live; ~${REQUIRED_DISK_GB} GB recommended."
warn "Use --skip-model, or set COMFY_ROOT to a roomier disk."
else
ok "Disk space: ${avail_gb} GB available"
fi
if have ffmpeg; then
ok "ffmpeg found at $(command -v ffmpeg) (used by lektor_ai_comfy.py, not by ComfyUI)"
else
warn "ffmpeg not found. ComfyUI does not need it, but lektor_ai_comfy.py does."
warn "Install it with: conda install -n $ENV_NAME -c conda-forge ffmpeg (no sudo)"
fi
if (( USE_LATEST )); then
warn "--latest: installing newest ComfyUI and TTS-Audio-Suite rather than the pinned"
warn "versions this project was validated against. Expect to debug breakage yourself."
else
ok "Pinned: ComfyUI ${COMFYUI_COMMIT:0:8} (v0.26.0), TTS-Audio-Suite $TTS_SUITE_TAG"
fi
# ── 1. Miniforge ─────────────────────────────────────────────────────────────
step "Miniforge (self-contained conda in $CONDA_ROOT)"
if [[ -x "$CONDA_ROOT/bin/conda" ]]; then
ok "Already installed: $("$CONDA_ROOT/bin/conda" --version)"
else
tmp_installer="$(mktemp -t miniforge-XXXXXX.sh)"
trap 'rm -f "$tmp_installer"' EXIT
echo " Downloading Miniforge…"
"${DL[@]}" "$tmp_installer" "$MINIFORGE_URL"
echo " Installing to $CONDA_ROOT (batch mode, no shell integration)…"
bash "$tmp_installer" -b -p "$CONDA_ROOT"
rm -f "$tmp_installer"; trap - EXIT
ok "Installed $("$CONDA_ROOT/bin/conda" --version)"
fi
# ── 2. Conda environment ─────────────────────────────────────────────────────
step "Conda environment '$ENV_NAME' (Python $PYTHON_VERSION)"
# shellcheck disable=SC1091
source "$CONDA_ROOT/etc/profile.d/conda.sh"
if conda env list | awk '{print $1}' | grep -qx "$ENV_NAME"; then
ok "Environment already exists — reusing it"
else
conda create -n "$ENV_NAME" "python=$PYTHON_VERSION" -y
ok "Created"
fi
activate_env
ok "Active: $(python -V) at $(command -v python)"
[[ "$(command -v python)" == "$CONDA_ROOT/envs/$ENV_NAME/"* ]] \
|| die "Wrong Python active — refusing to install into the wrong environment."
pip_install --upgrade pip setuptools wheel >/dev/null
ok "pip toolchain up to date"
# ── Exact restore path ───────────────────────────────────────────────────────
if [[ -n "$LOCKFILE" ]]; then
step "Restoring exact package set from $LOCKFILE"
[[ -f "$LOCKFILE" ]] || die "Lockfile not found: $LOCKFILE"
pip_install --extra-index-url "$TORCH_INDEX" -r "$LOCKFILE"
ok "Lockfile applied — skipping individual package steps"
fi
# ── 3. PyTorch ───────────────────────────────────────────────────────────────
step "PyTorch (CUDA 12.8 wheels)"
if [[ -z "$LOCKFILE" ]]; then
if (( USE_LATEST )); then
pip_install --index-url "$TORCH_INDEX" torch torchvision torchaudio
else
pip_install --index-url "$TORCH_INDEX" "${TORCH_PINS[@]}"
fi
fi
python - <<'PY'
import torch
print(f" torch {torch.__version__} cuda_available={torch.cuda.is_available()}")
if torch.cuda.is_available():
print(f" device: {torch.cuda.get_device_name(0)}")
PY
ok "PyTorch ready"
# ── 4. ComfyUI ───────────────────────────────────────────────────────────────
step "ComfyUI"
mkdir -p "$COMFY_ROOT"
COMFY_DIR="$COMFY_ROOT/ComfyUI"
if [[ -d "$COMFY_DIR/.git" ]]; then
ok "Repository already present at $COMFY_DIR"
else
git clone "$COMFYUI_REPO" "$COMFY_DIR"
fi
if (( ! USE_LATEST )); then
git -C "$COMFY_DIR" fetch --quiet origin
git -C "$COMFY_DIR" checkout --quiet "$COMFYUI_COMMIT"
ok "Checked out $COMFYUI_COMMIT (detached HEAD — expected)"
else
git -C "$COMFY_DIR" pull --quiet --ff-only origin master || warn "Could not fast-forward; leaving as-is"
fi
[[ -z "$LOCKFILE" ]] && pip_install -r "$COMFY_DIR/requirements.txt"
ok "ComfyUI dependencies installed"
# ── 5. TTS-Audio-Suite ───────────────────────────────────────────────────────
step "TTS-Audio-Suite custom node"
SUITE_DIR="$COMFY_DIR/custom_nodes/TTS-Audio-Suite"
mkdir -p "$COMFY_DIR/custom_nodes"
if [[ -d "$SUITE_DIR/.git" ]]; then
ok "Repository already present"
else
git clone "$TTS_SUITE_REPO" "$SUITE_DIR"
fi
if (( ! USE_LATEST )); then
git -C "$SUITE_DIR" fetch --quiet --tags origin
git -C "$SUITE_DIR" checkout --quiet "tags/$TTS_SUITE_TAG"
ok "Checked out $TTS_SUITE_TAG"
else
git -C "$SUITE_DIR" pull --quiet --ff-only || warn "Could not fast-forward; leaving as-is"
fi
if [[ -z "$LOCKFILE" ]]; then
# install.py resolves this suite's many conflicting dependencies itself
# (selective --no-deps installs, numpy pinning). Do not replace it with a
# plain `pip install -r requirements.txt` — that file deliberately omits
# the packages that need special handling.
echo " Running the suite's own installer (this pulls a lot and takes a while)…"
( cd "$SUITE_DIR" && python install.py )
fi
ok "TTS-Audio-Suite installed"
# ── 6. Model ─────────────────────────────────────────────────────────────────
step "Higgs Audio V3 model ($HF_MODEL_REPO)"
MODEL_DIR="$COMFY_DIR/$MODEL_SUBDIR"
if (( SKIP_MODEL )); then
warn "Skipped (--skip-model). It downloads automatically on the first generation."
elif [[ -f "$MODEL_DIR/model.safetensors" ]]; then
ok "Already present ($(du -h "$MODEL_DIR/model.safetensors" | cut -f1))"
else
echo " Downloading ~8.7 GB into $MODEL_DIR …"
mkdir -p "$MODEL_DIR"
if ! HF_MODEL_REPO="$HF_MODEL_REPO" MODEL_DIR="$MODEL_DIR" python - <<'PY'
import os, sys
from huggingface_hub import snapshot_download
try:
snapshot_download(repo_id=os.environ["HF_MODEL_REPO"],
local_dir=os.environ["MODEL_DIR"])
except Exception as exc:
print(f"download failed: {exc}", file=sys.stderr)
sys.exit(1)
PY
then
warn "Model download failed."
warn "This model is licensed for research / non-commercial use and may require"
warn "accepting its terms on huggingface.co first, then: hf auth login"
warn "The suite will retry automatically on the first generation."
else
ok "Model downloaded"
fi
fi
# ── 7. Launcher ──────────────────────────────────────────────────────────────
step "Launcher script"
LAUNCHER="$COMFY_ROOT/start_comfyui.sh"
cat > "$LAUNCHER" <<EOF
#!/bin/bash
# Generated by install_comfyui.sh — starts ComfyUI on port $COMFY_PORT.
source "$CONDA_ROOT/etc/profile.d/conda.sh"
conda activate $ENV_NAME
cd "$COMFY_DIR" && python main.py --port $COMFY_PORT
EOF
chmod +x "$LAUNCHER"
ok "Wrote $LAUNCHER"
# ── 8. Verification ──────────────────────────────────────────────────────────
step "Verifying installation"
python - <<'PY'
import importlib, sys
failed = []
for mod in ("torch", "torchaudio", "transformers", "safetensors", "soundfile", "librosa"):
try:
m = importlib.import_module(mod)
print(f" [+] {mod:<13} {getattr(m, '__version__', '?')}")
except Exception as exc:
print(f" [X] {mod:<13} {exc}")
failed.append(mod)
sys.exit(1 if failed else 0)
PY
ok "Core imports succeed"
echo
echo "${B}${G}Installation complete.${N}"
echo
echo " Start ComfyUI: $LAUNCHER"
echo " Web UI: http://127.0.0.1:$COMFY_PORT"
echo " Then run: python3 lektor_ai_comfy.py <video.mkv> --embedded-subs"
echo
echo " lektor_ai_comfy.py expects these paths — edit its constants if you changed them:"
echo " COMFY_URL = \"http://127.0.0.1:$COMFY_PORT\""
echo " COMFY_OUTPUT_DIR = \"$COMFY_DIR/output\""
echo " COMFY_INPUT_DIR = \"$COMFY_DIR/input\""
echo
echo " Record this environment for later:"
echo " source $CONDA_ROOT/etc/profile.d/conda.sh && conda activate $ENV_NAME"
echo " pip freeze > comfyui-requirements.lock.txt"
echo
echo " Remove everything:"
echo " conda env remove -n $ENV_NAME && rm -rf $COMFY_ROOT"
echo " rm -rf $CONDA_ROOT # only if nothing else uses it"
+389
View File
@@ -0,0 +1,389 @@
#!/usr/bin/env python3
"""
lektor_ai — Convert SRT subtitles to AI voiceover using Boson AI TTS (higgs-audio-v3),
then mix the result with a video file's original audio.
Usage:
python3 lektor_ai.py input/film.srt input/film.mkv --start 04:07 --end 05:45
Requires a free Boson AI API key from https://boson.ai/workspace
Set it via: export BOSON_API_KEY=bai-xxxx
"""
import argparse
import base64
import os
import re
import subprocess
import sys
from pathlib import Path
# ---------------------------------------------------------------------------
# SRT helpers
# ---------------------------------------------------------------------------
_SRT_TIME_RE = re.compile(r'(\d+):(\d+):(\d+)[,.](\d+)')
def _parse_srt_time(s: str) -> float:
m = _SRT_TIME_RE.match(s.strip())
if not m:
raise ValueError(f"Unrecognised SRT timestamp: {s!r}")
h, mn, sec, ms = int(m[1]), int(m[2]), int(m[3]), int(m[4])
return h * 3600 + mn * 60 + sec + ms / 1000
def _fmt_srt_time(seconds: float) -> str:
h = int(seconds // 3600)
m = int((seconds % 3600) // 60)
s = int(seconds % 60)
ms = int(round((seconds % 1) * 1000))
if ms == 1000:
ms = 999
return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
def _parse_mmss(value: str) -> float:
"""Accept HH:MM:SS or MM:SS."""
parts = value.strip().split(":")
if len(parts) == 2:
return int(parts[0]) * 60 + float(parts[1])
if len(parts) == 3:
return int(parts[0]) * 3600 + int(parts[1]) * 60 + float(parts[2])
raise ValueError(f"Cannot parse time: {value!r}")
def _clean_text(lines: list) -> str:
"""Strip dialogue dashes and return plain text."""
out = []
for line in lines:
line = line.strip()
line = re.sub(r"^-\s*", "", line)
if line:
out.append(line)
return "\n".join(out)
def parse_subtitle_blocks(srt_path: str, start_sec: float, end_sec: float) -> list:
"""
Return list of (start_relative, end_relative, plain_text) for each subtitle
block whose start falls within [start_sec, end_sec].
Times are relative to start_sec.
"""
text = Path(srt_path).read_text(encoding="utf-8-sig")
blocks = re.split(r"\n{2,}", text.strip())
result = []
for block in blocks:
lines = block.strip().splitlines()
ti = next((i for i, l in enumerate(lines) if "-->" in l), None)
if ti is None:
continue
parts = lines[ti].split("-->")
if len(parts) != 2:
continue
t_start = _parse_srt_time(parts[0])
t_end = _parse_srt_time(parts[1])
if t_start < start_sec or t_start > end_sec:
continue
plain = _clean_text(lines[ti + 1:])
if plain:
result.append((t_start - start_sec, t_end - start_sec, plain))
return result
# ---------------------------------------------------------------------------
# FFmpeg helpers
# ---------------------------------------------------------------------------
def _ffmpeg_ts(seconds: float) -> str:
h = int(seconds // 3600)
m = int((seconds % 3600) // 60)
s = seconds % 60
return f"{h:02d}:{m:02d}:{s:06.3f}"
def _run(cmd: list, label: str):
print(f" {label}...")
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
print(f"ffmpeg error ({label}):\n{result.stderr[-2000:]}", file=sys.stderr)
sys.exit(1)
def extract_video_fragment(video_path: str, start_sec: float, end_sec: float,
output_path: str):
duration = end_sec - start_sec
_run([
"ffmpeg", "-y",
"-ss", _ffmpeg_ts(start_sec),
"-i", video_path,
"-t", str(duration),
"-c", "copy",
output_path,
], f"Extracting video {_ffmpeg_ts(start_sec)}–{_ffmpeg_ts(end_sec)}")
def _assemble_batch(clips: list, output_path: str, label: str):
"""Mix a batch of (start_sec, path) clips with absolute adelay into output_path."""
if len(clips) == 1:
delay_ms = int(clips[0][0] * 1000)
_run([
"ffmpeg", "-y",
"-i", str(clips[0][1]),
"-af", f"adelay={delay_ms}:all=1",
output_path,
], label)
return
inputs, filter_parts = [], []
for i, (start_sec, path) in enumerate(clips):
inputs += ["-i", str(path)]
delay_ms = int(start_sec * 1000)
filter_parts.append(f"[{i}:a]adelay={delay_ms}:all=1[a{i}]")
mix_inputs = "".join(f"[a{i}]" for i in range(len(clips)))
filter_parts.append(
f"{mix_inputs}amix=inputs={len(clips)}:duration=longest:normalize=0[out]"
)
_run(
["ffmpeg", "-y"] + inputs +
["-filter_complex", ";".join(filter_parts), "-map", "[out]", output_path],
label,
)
def assemble_timed_audio(clips: list, output_path: str, batch_size: int = 500):
"""
Place each audio clip at its correct timestamp using adelay, then mix.
clips: list of (start_sec, audio_path).
Processes in batches to stay under OS file-descriptor limits.
"""
if len(clips) <= batch_size:
_assemble_batch(clips, output_path, f"Assembling {len(clips)} timed clips")
return
tmp_dir = Path(output_path).parent / "_batch_tmp"
tmp_dir.mkdir(exist_ok=True)
batch_files = []
try:
for b, start in enumerate(range(0, len(clips), batch_size)):
batch = clips[start:start + batch_size]
batch_path = str(tmp_dir / f"batch_{b:04d}.flac")
_assemble_batch(
batch, batch_path,
f"Assembling batch {b + 1}/{(len(clips) - 1) // batch_size + 1} ({len(batch)} clips)",
)
batch_files.append(batch_path)
# merge batch files — they already carry absolute timing, so delay=0
batch_clips = [(0.0, Path(p)) for p in batch_files]
_assemble_batch(batch_clips, output_path, f"Merging {len(batch_files)} batches")
finally:
for p in batch_files:
Path(p).unlink(missing_ok=True)
tmp_dir.rmdir() if tmp_dir.exists() and not any(tmp_dir.iterdir()) else None
def merge_voiceover(video_path: str, tts_path: str, output_path: str,
orig_volume: float = 0.75):
_run([
"ffmpeg", "-y",
"-i", video_path,
"-i", tts_path,
"-filter_complex",
(
f"[0:a]volume={orig_volume}[orig];"
"[1:a]volume=1.0[tts];"
"[orig][tts]amix=inputs=2:duration=first:normalize=0[out]"
),
"-map", "0:v",
"-map", "[out]",
"-c:v", "copy",
"-c:a", "aac",
"-b:a", "192k",
output_path,
], "Merging voiceover with video")
# ---------------------------------------------------------------------------
# Boson AI TTS (higgs-audio-v3, OpenAI-compatible API)
# ---------------------------------------------------------------------------
_LOCAL_API_BASE = "http://localhost:8000/v1"
_BOSON_API_BASE = "https://api.boson.ai/v1"
_BOSON_MODEL = "higgs-audio-v3-tts"
_VLLM_MODEL = "bosonai/higgs-audio-v3-tts-4b"
_VOICE_PROMPTS_DIR = "/home/lukas/higgs-audio/examples/voice_prompts"
def _normalize_tts_text(text: str) -> str:
text = text.replace("\n", " ").strip()
text = re.sub(r"\s+", " ", text)
text = text.replace("(", " ").replace(")", " ")
if text and text[-1] not in ".!?,;\"'":
text += "."
return text.strip()
def _load_voice_prompt(voice: str) -> tuple:
"""Return (ref_audio_b64, ref_text) for the given voice name or wav path."""
wav_path = Path(voice)
if not wav_path.exists():
wav_path = Path(_VOICE_PROMPTS_DIR) / f"{voice}.wav"
txt_path = wav_path.with_suffix(".txt")
if not wav_path.exists():
raise FileNotFoundError(f"Voice prompt WAV not found: {wav_path}")
ref_audio_b64 = base64.b64encode(wav_path.read_bytes()).decode()
ref_text = txt_path.read_text(encoding="utf-8").strip() if txt_path.exists() else ""
return ref_audio_b64, ref_text
def generate_tts_per_block(
blocks: list,
download_dir: str,
voice: str = "gudowski",
api_key: str = "",
local: bool = True,
) -> list:
"""
Generate one audio clip per subtitle block.
local=True → calls local vllm-omni server at localhost:8095 (no API key needed)
local=False → calls Boson AI cloud API (needs BOSON_API_KEY)
blocks: list of (start_sec, end_sec, plain_text)
Returns list of (start_sec, audio_Path).
"""
try:
from openai import OpenAI
except ImportError:
sys.exit("openai package not found. Run: pip install openai")
if local:
client = OpenAI(api_key="local", base_url=_LOCAL_API_BASE)
model = _VLLM_MODEL
print(f" Using local vllm-omni server at {_LOCAL_API_BASE}")
else:
if not api_key:
api_key = os.environ.get("BOSON_API_KEY", "")
if not api_key:
sys.exit(
"Boson AI API key required.\n"
"Get a free key at https://boson.ai/workspace then:\n"
" export BOSON_API_KEY=bai-xxxx"
)
client = OpenAI(api_key=api_key, base_url=_BOSON_API_BASE)
model = _BOSON_MODEL
print(f" Using Boson AI cloud API")
ref_audio_b64, ref_text = _load_voice_prompt(voice)
print(f" Voice: {voice} (ref text: {ref_text[:60]!r}…)")
dl_dir = Path(download_dir).resolve()
dl_dir.mkdir(parents=True, exist_ok=True)
results = []
total = len(blocks)
for idx, (start_sec, end_sec, text) in enumerate(blocks):
text = _normalize_tts_text(text)
print(f" [{idx+1}/{total}] {_fmt_srt_time(start_sec)} {text[:70]!r}")
response = client.audio.speech.create(
model=model,
input=text,
voice="alloy",
response_format="mp3",
extra_body={
"ref_audio": f"data:audio/wav;base64,{ref_audio_b64}",
"ref_text": ref_text,
},
)
out_path = dl_dir / f"clip_{idx+1:04d}.mp3"
out_path.write_bytes(response.content)
results.append((start_sec, out_path))
return results
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(
description="Generate per-subtitle AI voiceover using Boson AI TTS (higgs-audio-v3)."
)
parser.add_argument("srt", help="Input SRT subtitle file")
parser.add_argument("video", help="Input video file (MKV, MP4, …)")
parser.add_argument("--start", default="00:00", metavar="MM:SS",
help="Fragment start time (default: 00:00)")
parser.add_argument("--end", default=None, metavar="MM:SS",
help="Fragment end time")
parser.add_argument("--voice", default="gudowski",
help="Voice prompt name or WAV path (default: gudowski)")
parser.add_argument("--cloud", action="store_true",
help="Use Boson AI cloud API instead of local vllm-omni server")
parser.add_argument("--api-key", default="",
help="Boson AI API key for --cloud mode (or set BOSON_API_KEY)")
parser.add_argument("--orig-volume", type=float, default=0.75, metavar="0-1",
help="Original audio volume in mix (default: 0.75)")
parser.add_argument("--output-dir", default="output",
help="Output directory (default: output/)")
parser.add_argument("--skip-tts", metavar="AUDIO_FILE",
help="Skip TTS and use this pre-assembled audio file")
args = parser.parse_args()
out_dir = Path(args.output_dir)
out_dir.mkdir(parents=True, exist_ok=True)
start_sec = _parse_mmss(args.start)
end_sec = _parse_mmss(args.end) if args.end else None
# ── Step 1: Parse subtitle blocks ─────────────────────────────────────────
print(f"\n[1/4] Parsing subtitles {args.start}–{args.end or 'end'}…")
if end_sec is None:
end_sec = 99 * 3600
blocks = parse_subtitle_blocks(args.srt, start_sec, end_sec)
if not blocks:
sys.exit("No subtitle blocks found in the specified range.")
print(f" {len(blocks)} subtitle block(s).")
# ── Step 2: Generate TTS per block ────────────────────────────────────────
if args.skip_tts:
assembled_tts = Path(args.skip_tts)
print(f"\n[2/4] Using existing TTS audio: {assembled_tts}")
else:
print(f"\n[2/4] Generating TTS ({len(blocks)} clips via Boson AI)…")
clips = generate_tts_per_block(
blocks,
str(out_dir),
voice=args.voice,
api_key=args.api_key,
local=not args.cloud,
)
assembled_tts = out_dir / "tts_assembled.wav"
print(f"\n Assembling timed audio…")
assemble_timed_audio(clips, str(assembled_tts))
# ── Step 3: Extract video fragment ───────────────────────────────────────
print(f"\n[3/4] Extracting video fragment…")
real_end = _parse_mmss(args.end) if args.end else None
if real_end is not None:
video_fragment = out_dir / "video_fragment.mkv"
extract_video_fragment(args.video, start_sec, real_end, str(video_fragment))
else:
video_fragment = Path(args.video)
# ── Step 4: Mix voiceover with video ─────────────────────────────────────
stem = Path(args.video).stem
final_output = out_dir / f"{stem}_lektor.mp4"
print(f"\n[4/4] Mixing (TTS 100%, original at {int(args.orig_volume * 100)}%)…")
merge_voiceover(str(video_fragment), str(assembled_tts), str(final_output),
orig_volume=args.orig_volume)
print(f"\nDone! Output: {final_output}")
if __name__ == "__main__":
main()
+1064
View File
File diff suppressed because it is too large Load Diff
+884
View File
@@ -0,0 +1,884 @@
#!/usr/bin/env python3
"""
lektor_ai_comfy_v2 — SRT-to-voiceover via ComfyUI + Higgs Audio V3.
Sends subtitles in batches (default: 5) so the TTS model sees multi-sentence
context, which can improve prosody and naturalness compared to per-clip generation.
Usage:
python3 lektor_ai_comfy_v2.py input/film.srt input/film.mkv --start 00:00 --end 01:40
Requires ComfyUI with TTS-Audio-Suite running at http://127.0.0.1:8189
"""
import argparse
import json
import random
import re
import shutil
import subprocess
import sys
import time
import uuid
from pathlib import Path
from urllib.request import urlopen, Request
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
COMFY_URL = "http://127.0.0.1:8189"
COMFY_OUTPUT_DIR = Path("/home/lukas/comfy-tts-app/ComfyUI/output")
COMFY_INPUT_DIR = Path("/home/lukas/comfy-tts-app/ComfyUI/input")
VOICE_PROMPTS_DIR = Path("/home/lukas/Programy/lektor_ai/gudowski")
# ---------------------------------------------------------------------------
# SRT helpers
# ---------------------------------------------------------------------------
_SRT_TIME_RE = re.compile(r'(\d+):(\d+):(\d+)[,.](\d+)')
def _parse_srt_time(s: str) -> float:
m = _SRT_TIME_RE.match(s.strip())
if not m:
raise ValueError(f"Bad SRT timestamp: {s!r}")
h, mn, sec, ms = int(m[1]), int(m[2]), int(m[3]), int(m[4])
return h * 3600 + mn * 60 + sec + ms / 1000
def _fmt_srt_time(seconds: float) -> str:
h = int(seconds // 3600)
m = int((seconds % 3600) // 60)
s = int(seconds % 60)
ms = int(round((seconds % 1) * 1000))
if ms == 1000:
ms = 999
return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
def _parse_mmss(value: str) -> float:
parts = value.strip().split(":")
if len(parts) == 2:
return int(parts[0]) * 60 + float(parts[1])
if len(parts) == 3:
return int(parts[0]) * 3600 + int(parts[1]) * 60 + float(parts[2])
raise ValueError(f"Cannot parse time: {value!r}")
def _ensure_terminal_punct(text: str, single_dot: bool = False) -> str:
if not text or text[-1] in ('.', ',', '!', '?', ';', ':'):
return text
return text + ('.' if single_dot else '...')
def _clean_text(lines: list) -> str:
has_dialogue = any(re.match(r"^-\s*\S", l.strip()) for l in lines if l.strip())
out = []
for line in lines:
line = line.strip()
line = re.sub(r"^-\s*", "", line)
if line:
out.append(line)
result = "\n".join(out) if has_dialogue else " ".join(out)
letters = [c for c in result if c.isalpha()]
was_allcaps = bool(letters) and all(c == c.upper() for c in letters)
if was_allcaps:
result = result.lower()
if result and result[0].islower():
result = result[0].upper() + result[1:]
if has_dialogue:
result = "\n".join(_ensure_terminal_punct(p, was_allcaps) for p in result.split("\n"))
else:
result = _ensure_terminal_punct(result, was_allcaps)
return result
def load_tts_input_srt(path: Path) -> list:
"""Read a pre-processed tts_input.srt into (t_start, t_end, text) tuples."""
text = path.read_text(encoding="utf-8")
blocks = re.split(r"\n{2,}", text.strip())
result = []
for block in blocks:
lines = block.strip().splitlines()
ti = next((i for i, l in enumerate(lines) if "-->" in l), None)
if ti is None:
continue
parts = lines[ti].split("-->")
if len(parts) != 2:
continue
t_start = _parse_srt_time(parts[0])
t_end = _parse_srt_time(parts[1])
payload = " ".join(l.strip() for l in lines[ti + 1:] if l.strip())
if payload:
result.append((t_start, t_end, payload))
return result
def parse_subtitle_blocks(srt_path: str, start_sec: float, end_sec: float) -> list:
"""Return list of (start_relative, end_relative, plain_text) blocks."""
text = Path(srt_path).read_text(encoding="utf-8-sig")
blocks = re.split(r"\n{2,}", text.strip())
result = []
for block in blocks:
lines = block.strip().splitlines()
ti = next((i for i, l in enumerate(lines) if "-->" in l), None)
if ti is None:
continue
parts = lines[ti].split("-->")
if len(parts) != 2:
continue
t_start = _parse_srt_time(parts[0])
t_end = _parse_srt_time(parts[1])
if t_start < start_sec or t_start > end_sec:
continue
plain = _clean_text(lines[ti + 1:])
if plain:
result.append((t_start - start_sec, t_end - start_sec, plain))
return result
def split_dialogue_blocks(blocks: list) -> list:
"""Split multi-line blocks into one entry per line with proportional timing."""
result = []
for t_start, t_end, text in blocks:
lines = [l for l in text.split("\n") if l.strip()]
if len(lines) <= 1:
result.append((t_start, t_end, text))
continue
duration = t_end - t_start
total_chars = sum(len(l) for l in lines)
current = t_start
for i, line in enumerate(lines):
share = len(line) / total_chars if total_chars else 1 / len(lines)
end = t_end if i == len(lines) - 1 else current + duration * share
result.append((current, end, line))
current = end
return result
def blocks_to_srt(blocks: list) -> str:
"""Convert (start, end, text) tuples back to SRT string for inspection."""
lines = []
for i, (t_start, t_end, text) in enumerate(blocks, 1):
lines.append(str(i))
lines.append(f"{_fmt_srt_time(t_start)} --> {_fmt_srt_time(t_end)}")
lines.append(text)
lines.append("")
return "\n".join(lines)
# ---------------------------------------------------------------------------
# FFmpeg helpers
# ---------------------------------------------------------------------------
def _ffmpeg_ts(seconds: float) -> str:
h = int(seconds // 3600)
m = int((seconds % 3600) // 60)
s = seconds % 60
return f"{h:02d}:{m:02d}:{s:06.3f}"
def _run(cmd: list, label: str):
print(f" {label}...")
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
print(f"ffmpeg error ({label}):\n{result.stderr[-2000:]}", file=sys.stderr)
sys.exit(1)
def get_audio_duration(path: Path) -> float:
result = subprocess.run(
["ffprobe", "-v", "quiet", "-print_format", "json", "-show_streams", str(path)],
capture_output=True, text=True,
)
info = json.loads(result.stdout)
for stream in info.get("streams", []):
if "duration" in stream:
return float(stream["duration"])
raise RuntimeError(f"Cannot determine duration of {path}")
def speed_up_audio(input_path: Path, output_path: Path, speed: float,
fade_out_ms: float = 0):
"""Apply atempo (and optional fade-out) to a single clip."""
filters = []
if speed != 1.0:
remaining = speed
while remaining > 2.0:
filters.append("atempo=2.0")
remaining /= 2.0
filters.append(f"atempo={remaining:.4f}")
if fade_out_ms > 0:
fade_sec = fade_out_ms / 1000
raw_dur = get_audio_duration(input_path)
post_dur = raw_dur / speed if speed != 1.0 else raw_dur
fade_start = max(0.0, post_dur - fade_sec)
filters.append(f"afade=t=out:st={fade_start:.3f}:d={fade_sec:.3f}")
if not filters:
shutil.copy2(input_path, output_path)
return
label = f"Speed {speed:.2f}×"
if fade_out_ms:
label += f" + fade-out {fade_out_ms:.0f}ms"
_run([
"ffmpeg", "-y",
"-i", str(input_path),
"-filter:a", ",".join(filters),
str(output_path),
], label)
def strip_leading_audio(input_path: Path, output_path: Path, strip_ms: float):
"""Remove the first strip_ms milliseconds from a clip."""
_run([
"ffmpeg", "-y",
"-i", str(input_path),
"-ss", f"{strip_ms / 1000:.3f}",
"-c", "copy",
str(output_path),
], f"Strip {strip_ms:.0f}ms leading audio")
def _assemble_batch(clips: list, output_path: str, label: str):
"""Mix a batch of (start_sec, path) clips with absolute adelay into output_path."""
if len(clips) == 1:
delay_ms = int(clips[0][0] * 1000)
_run([
"ffmpeg", "-y",
"-i", str(clips[0][1]),
"-af", f"adelay={delay_ms}:all=1",
output_path,
], label)
return
inputs, filter_parts = [], []
for i, (start_sec, path) in enumerate(clips):
inputs += ["-i", str(path)]
delay_ms = int(start_sec * 1000)
filter_parts.append(f"[{i}:a]adelay={delay_ms}:all=1[a{i}]")
mix_inputs = "".join(f"[a{i}]" for i in range(len(clips)))
filter_parts.append(
f"{mix_inputs}amix=inputs={len(clips)}:duration=longest:normalize=0[out]"
)
_run(
["ffmpeg", "-y"] + inputs +
["-filter_complex", ";".join(filter_parts), "-map", "[out]", output_path],
label,
)
def assemble_timed_audio(clips: list, output_path: str, batch_size: int = 500):
"""Place each clip at its start time via adelay then mix.
clips: list of (start_sec, Path).
Processes in batches to stay under OS file-descriptor limits.
"""
if len(clips) <= batch_size:
_assemble_batch(clips, output_path, f"Assembling {len(clips)} timed clips")
return
tmp_dir = Path(output_path).parent / "_batch_tmp"
tmp_dir.mkdir(exist_ok=True)
batch_files = []
try:
for b, start in enumerate(range(0, len(clips), batch_size)):
batch = clips[start:start + batch_size]
batch_path = str(tmp_dir / f"batch_{b:04d}.flac")
_assemble_batch(
batch, batch_path,
f"Assembling batch {b + 1}/{(len(clips) - 1) // batch_size + 1} ({len(batch)} clips)",
)
batch_files.append(batch_path)
batch_clips = [(0.0, Path(p)) for p in batch_files]
_assemble_batch(batch_clips, output_path, f"Merging {len(batch_files)} batches")
finally:
for p in batch_files:
Path(p).unlink(missing_ok=True)
tmp_dir.rmdir() if tmp_dir.exists() and not any(tmp_dir.iterdir()) else None
def extract_video_fragment(video_path: str, start_sec: float, end_sec: float,
output_path: str):
_run([
"ffmpeg", "-y",
"-ss", _ffmpeg_ts(start_sec),
"-i", video_path,
"-t", str(end_sec - start_sec),
"-c", "copy",
output_path,
], f"Extracting video {_ffmpeg_ts(start_sec)}–{_ffmpeg_ts(end_sec)}")
def merge_voiceover(video_path: str, tts_path: str, output_path: str,
orig_volume: float = 0.60, tts_volume: float = 1.8):
"""Produce a video with two audio tracks:
track 0 — Original (first audio stream from source, re-encoded to AAC)
track 1 — Polish (lektor mix: TTS at tts_volume + original at orig_volume)
"""
probe = subprocess.run(
["ffprobe", "-v", "quiet", "-print_format", "json", "-show_streams", video_path],
capture_output=True, text=True,
)
streams = json.loads(probe.stdout).get("streams", [])
audio_start = next(
(float(s.get("start_time", 0)) for s in streams if s.get("codec_type") == "audio"),
0.0,
)
pts_reset = ",asetpts=PTS-STARTPTS" if audio_start > 0.01 else ""
if audio_start > 0.01:
print(f" Video audio start offset: {audio_start:.3f}s — resetting PTS in lektor mix")
_run([
"ffmpeg", "-y",
"-i", video_path,
"-i", tts_path,
"-filter_complex",
(
f"[0:a:0]volume={orig_volume}{pts_reset}[orig_mix];"
f"[1:a]volume={tts_volume}[tts];"
"[orig_mix][tts]amix=inputs=2:duration=longest:normalize=0[lektor]"
),
"-map", "0:v",
"-map", "0:a:0",
"-map", "[lektor]",
"-c:v", "copy",
"-c:a:0", "copy",
"-c:a:1", "aac", "-b:a:1", "192k",
"-metadata:s:a:0", "title=Original",
"-metadata:s:a:1", "title=Polish",
"-metadata:s:a:1", "language=pol",
output_path,
], "Merging voiceover with video (2 audio tracks)")
# ---------------------------------------------------------------------------
# ComfyUI API helpers
# ---------------------------------------------------------------------------
def _comfy_post(path: str, data: dict) -> dict:
payload = json.dumps(data).encode()
req = Request(
f"{COMFY_URL}{path}",
data=payload,
headers={"Content-Type": "application/json"},
)
with urlopen(req, timeout=30) as r:
return json.loads(r.read())
def _comfy_get(path: str) -> dict:
with urlopen(f"{COMFY_URL}{path}", timeout=30) as r:
return json.loads(r.read())
def upload_voice_wav(wav_path: Path) -> tuple:
"""Copy voice WAV into ComfyUI's input dir; return (filename, reference_text)."""
dst = COMFY_INPUT_DIR / wav_path.name
if not dst.exists() or dst.stat().st_mtime < wav_path.stat().st_mtime:
shutil.copy2(wav_path, dst)
print(f" Copied {wav_path.name} → {dst}")
txt_path = wav_path.with_suffix(".txt")
ref_text = txt_path.read_text(encoding="utf-8").strip() if txt_path.exists() else ""
if ref_text:
print(f" Reference text loaded ({len(ref_text)} chars)")
else:
print(" WARNING: no reference .txt found — voice cloning quality will be lower")
return wav_path.name, ref_text
def build_text_workflow(text: str, voice_filename: str, voice_ref_text: str,
seed: int, temperature: float = 0.9,
max_new_tokens: int = 4096) -> dict:
"""ComfyUI workflow for a single text utterance with proper voice reference."""
return {
"1": {
"class_type": "HiggsAudioV3EngineNode",
"inputs": {
"model": "higgs-audio-v3-tts-4b",
"device": "auto",
"dtype": "auto",
"attention": "auto",
"temperature": temperature,
"top_p": 0.95,
"top_k": 50,
"max_new_tokens": max_new_tokens,
},
},
"2": {
"class_type": "LoadAudio",
"inputs": {"audio": voice_filename},
},
"3": {
"class_type": "CharacterVoicesNode",
"inputs": {
"voice_name": "none",
"reference_text": voice_ref_text,
"opt_audio_input": ["2", 0],
},
},
"4": {
"class_type": "UnifiedTTSTextNode",
"inputs": {
"TTS_engine": ["1", 0],
"text": text,
"narrator_voice": "none",
"seed": seed,
"opt_narrator": ["3", 0],
"enable_audio_cache": False,
},
},
"5": {
"class_type": "SaveAudio",
"inputs": {
"audio": ["4", 0],
"filename_prefix": "audio/lektor_clip",
},
},
}
def submit_workflow(workflow: dict) -> str:
client_id = str(uuid.uuid4())
resp = _comfy_post("/prompt", {"prompt": workflow, "client_id": client_id})
return resp["prompt_id"]
def wait_for_completion(prompt_id: str, poll_interval: int = 3) -> dict:
print(f" Waiting for {prompt_id[:8]}…", end="", flush=True)
while True:
try:
history = _comfy_get(f"/history/{prompt_id}")
except Exception as e:
print(f"\n Poll error: {e}", file=sys.stderr)
time.sleep(poll_interval)
continue
if prompt_id in history:
entry = history[prompt_id]
status = entry.get("status", {})
for msg_type, msg_data in status.get("messages", []):
if msg_type == "execution_error":
print(f"\nComfyUI error: {msg_data}", file=sys.stderr)
sys.exit(1)
if status.get("completed"):
print(" done.")
return entry
print(".", end="", flush=True)
time.sleep(poll_interval)
def extract_output_audio(history_entry: dict) -> Path:
outputs = history_entry.get("outputs", {})
for node_output in outputs.values():
for audio_info in node_output.get("audio", []):
subfolder = audio_info.get("subfolder", "")
path = COMFY_OUTPUT_DIR / subfolder / audio_info["filename"]
if path.exists():
return path
raise RuntimeError(f"Output audio not found in history: {outputs}")
def _generate_one(text: str, voice_filename: str, voice_ref_text: str,
seed: int, speed: float, out_dir: Path, label: str,
strip_leading_ms: float, temperature: float = 0.9,
trailing_fade_ms: float = 0, max_new_tokens: int = 4096) -> tuple:
"""Submit one TTS job and return (path, duration_after_speedup)."""
workflow = build_text_workflow(text, voice_filename, voice_ref_text, seed,
temperature, max_new_tokens)
prompt_id = submit_workflow(workflow)
history_entry = wait_for_completion(prompt_id)
raw_clip = extract_output_audio(history_entry)
if strip_leading_ms > 0:
stripped = out_dir / f"{label}_s.flac"
strip_leading_audio(raw_clip, stripped, strip_leading_ms)
raw_clip = stripped
fast_clip = out_dir / f"{label}.flac"
speed_up_audio(raw_clip, fast_clip, speed, fade_out_ms=trailing_fade_ms)
return fast_clip, get_audio_duration(fast_clip)
# ---------------------------------------------------------------------------
# Batch generation
# ---------------------------------------------------------------------------
def _group_into_batches(blocks: list, batch_size: int) -> list:
"""Group (t_start, t_end, text) blocks into batches of batch_size.
Returns list of dicts:
batch_num — 1-based batch index
t_start — SRT start of the first block in the batch
t_end — SRT end of the last block in the batch
block_indices — 1-based indices of the blocks that belong to this batch
texts — list of individual block texts (already have terminal punct)
"""
batches = []
for i in range(0, len(blocks), batch_size):
group = blocks[i:i + batch_size]
batches.append({
"batch_num": len(batches) + 1,
"t_start": group[0][0],
"t_end": group[-1][1],
"block_indices": list(range(i + 1, i + len(group) + 1)),
"texts": [text for _, _, text in group],
})
return batches
def _join_batch_texts(texts: list) -> str:
"""Join multiple subtitle texts into a single TTS prompt.
Each text already ends with punctuation from _clean_text. We join with a
space so the model reads them as one continuous multi-sentence utterance.
"""
return " ".join(texts)
def generate_batch_clips(
blocks: list, batch_size: int,
voice_filename: str, voice_ref_text: str,
base_seed: int, speed: float, clips_dir: Path,
strip_leading_ms: float = 0.0,
resume_from: int = 0,
temperature: float = 0.9,
trailing_fade_ms: float = 0,
max_new_tokens: int = 4096,
) -> tuple:
"""
Generate one audio clip per batch of `batch_size` consecutive subtitle blocks.
The texts of all blocks in a batch are joined into one TTS call, giving the
model sentence-level context to produce more natural prosody.
resume_from: skip batches 1..(resume_from-1) by loading existing clip files.
Returns (clips, batches) where:
clips — list of (actual_start_sec, clip_path) for assembly
batches — list of batch metadata dicts (saved to batches.json)
"""
batches = _group_into_batches(blocks, batch_size)
total = len(batches)
clips = []
cursor = 0.0
for batch in batches:
batch_num = batch["batch_num"]
t_start = batch["t_start"]
label = f"clip_{batch_num:04d}"
canonical = clips_dir / f"{label}.flac"
if resume_from > 0 and batch_num < resume_from:
if not canonical.exists():
sys.exit(
f"Resume from batch {resume_from}: {canonical} not found — "
f"lower --resume-from or regenerate from an earlier batch."
)
duration = get_audio_duration(canonical)
blocks_str = f"blocks {batch['block_indices'][0]}–{batch['block_indices'][-1]}"
print(f" [batch {batch_num}/{total}] skip (resuming) — "
f"{blocks_str} {canonical.name} {duration:.2f}s")
actual_start = max(t_start, cursor)
clips.append((actual_start, canonical))
cursor = actual_start + duration
continue
blocks_str = (
f"block {batch['block_indices'][0]}"
if len(batch["block_indices"]) == 1
else f"blocks {batch['block_indices'][0]}–{batch['block_indices'][-1]}"
)
print(f"\n [batch {batch_num}/{total}] {blocks_str} @ {_fmt_srt_time(t_start)}")
for i, text in enumerate(batch["texts"]):
print(f" [{i + 1}] {text[:80]!r}")
joined = _join_batch_texts(batch["texts"])
seed = base_seed + batch_num - 1
fast_clip, duration = _generate_one(
joined, voice_filename, voice_ref_text,
seed, speed, clips_dir, label, strip_leading_ms, temperature,
trailing_fade_ms=trailing_fade_ms, max_new_tokens=max_new_tokens,
)
actual_start = max(t_start, cursor)
clips.append((actual_start, fast_clip))
cursor = actual_start + duration
delay = actual_start - t_start
delay_str = f" [+{delay:.2f}s behind SRT]" if delay > 0.05 else ""
print(f" → placed at {_fmt_srt_time(actual_start)}, "
f"duration {duration:.2f}s{delay_str}")
return clips, batches
# ---------------------------------------------------------------------------
# Rebuild / Regen helpers
# ---------------------------------------------------------------------------
def _rebuild_video(args, out_dir: Path):
"""Re-assemble audio from existing batch clips and rebuild the final video."""
batches_json = out_dir / "batches.json"
if not batches_json.exists():
sys.exit(f"No batches.json found in {out_dir} — run a full v2 generation first.")
batches = json.loads(batches_json.read_text(encoding="utf-8"))
total = len(batches)
clips_dir = out_dir / "clips"
missing = [
clips_dir / f"clip_{b['batch_num']:04d}.flac"
for b in batches
if not (clips_dir / f"clip_{b['batch_num']:04d}.flac").exists()
]
if missing:
sys.exit("Missing batch clip files:\n "
+ "\n ".join(str(p) for p in missing[:10]))
clips = []
cursor = 0.0
for batch in batches:
clip_path = clips_dir / f"clip_{batch['batch_num']:04d}.flac"
duration = get_audio_duration(clip_path)
t_start = batch["t_start"]
actual_start = max(t_start, cursor)
clips.append((actual_start, clip_path))
cursor = actual_start + duration
assembled_tts = out_dir / "tts_assembled.flac"
print(f"\n Assembling {total} batch clips…")
assemble_timed_audio(clips, str(assembled_tts))
print(f" Assembled: {assembled_tts} ({assembled_tts.stat().st_size // 1024} KB)")
if args.end:
start_sec = _parse_mmss(args.start)
end_sec = _parse_mmss(args.end)
video_fragment = out_dir / "video_fragment.mkv"
if not video_fragment.exists():
extract_video_fragment(args.video, start_sec, end_sec, str(video_fragment))
video_src = str(video_fragment)
else:
video_src = args.video
stem = Path(args.video).stem
final_output = out_dir / f"{stem}_lektor.mp4"
print(f"\n Building 2-track video "
f"(TTS {int(args.tts_volume * 100)}% / orig {int(args.orig_volume * 100)}%)…")
merge_voiceover(video_src, str(assembled_tts), str(final_output),
orig_volume=args.orig_volume, tts_volume=args.tts_volume)
print(f"\nDone! Output: {final_output}")
def _regen_clips(args, out_dir: Path):
"""Regenerate specific batch clips by batch number.
Only replaces FLAC files — run --rebuild afterwards to reassemble audio and video.
"""
batches_json = out_dir / "batches.json"
if not batches_json.exists():
sys.exit(f"No batches.json found in {out_dir} — run a full v2 generation first.")
batches = json.loads(batches_json.read_text(encoding="utf-8"))
total = len(batches)
regen_set = set(args.regen)
clips_dir = out_dir / "clips"
clips_dir.mkdir(exist_ok=True)
bad = [n for n in regen_set if n < 1 or n > total]
if bad:
sys.exit(f"Batch number(s) out of range (1–{total}): {bad}")
wav_path = Path(args.voice)
if not wav_path.is_file():
wav_path = VOICE_PROMPTS_DIR / f"{args.voice}.wav"
if not wav_path.is_file():
sys.exit(f"Voice WAV not found: {wav_path}")
voice_filename, voice_ref_text = upload_voice_wav(wav_path)
base_seed = args.seed if args.seed != 0 else random.randint(1, 2**31 - 1)
print(f"\n Regenerating batches {sorted(regen_set)} with seed {base_seed}…")
for batch in batches:
batch_num = batch["batch_num"]
if batch_num not in regen_set:
continue
joined = _join_batch_texts(batch["texts"])
label = f"clip_{batch_num:04d}"
blocks_str = f"blocks {batch['block_indices'][0]}–{batch['block_indices'][-1]}"
print(f"\n REGEN [batch {batch_num}/{total}] {blocks_str}")
for i, text in enumerate(batch["texts"]):
print(f" [{i + 1}] {text[:80]!r}")
seed = base_seed + batch_num - 1
clip_path, duration = _generate_one(
joined, voice_filename, voice_ref_text,
seed, args.speed, clips_dir, label,
args.strip_leading_ms, args.temperature,
trailing_fade_ms=args.trailing_fade_ms,
max_new_tokens=args.max_tokens,
)
print(f" → saved as {clip_path.name} ({duration:.2f}s)")
print(f"\nDone. Run --rebuild to reassemble audio and rebuild the video.")
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main():
global COMFY_URL
parser = argparse.ArgumentParser(
description=(
"Generate AI voiceover via ComfyUI + Higgs Audio V3, in batches of "
"N subtitle blocks per TTS call for contextual multi-sentence generation."
)
)
parser.add_argument("srt", help="Input SRT subtitle file")
parser.add_argument("video", help="Input video file (MKV, MP4, …)")
parser.add_argument("--start", default="00:00", metavar="MM:SS",
help="Fragment start time (default: 00:00)")
parser.add_argument("--end", default=None, metavar="MM:SS",
help="Fragment end time")
parser.add_argument("--voice", default="gudowski_short",
help="Voice prompt name or WAV path (default: gudowski_short)")
parser.add_argument("--batch-size", type=int, default=5, metavar="N",
help="Subtitle blocks per TTS call (default: 5). "
"Larger batches give the model more context but produce "
"longer clips that are harder to regen individually.")
parser.add_argument("--speed", type=float, default=1.2, metavar="FACTOR",
help="Per-clip playback speed via atempo (default: 1.2)")
parser.add_argument("--seed", type=int, default=0,
help="Base generation seed (default: 0 = random each run)")
parser.add_argument("--orig-volume", type=float, default=0.60, metavar="0-1",
help="Original audio volume in mix (default: 0.60)")
parser.add_argument("--tts-volume", type=float, default=1.8, metavar="0-2",
help="Voiceover volume in mix (default: 1.8)")
parser.add_argument("--output-dir", default="output",
help="Output directory (default: output/)")
parser.add_argument("--comfy-url", default=COMFY_URL,
help=f"ComfyUI URL (default: {COMFY_URL})")
parser.add_argument("--skip-tts", metavar="AUDIO_FILE",
help="Skip TTS and use this pre-assembled audio file")
parser.add_argument("--temperature", type=float, default=0.9, metavar="T",
help="Sampling temperature (default: 0.9)")
parser.add_argument("--strip-leading-ms", type=float, default=0.0, metavar="MS",
help="Strip N ms from the start of each clip (default: 0)")
parser.add_argument("--trailing-fade-ms", type=float, default=80.0, metavar="MS",
help="Fade out the last N ms of each clip (default: 80)")
parser.add_argument("--max-tokens", type=int, default=4096, metavar="N",
help="Max audio tokens per TTS call (default: 4096). "
"Raise if batch clips are cut off mid-sentence.")
parser.add_argument("--resume-from", type=int, default=0, metavar="N",
help="Resume a crashed run: skip batches 1..(N-1) by loading "
"their existing clip files, generate from batch N onwards.")
parser.add_argument("--rebuild", action="store_true",
help="Re-assemble audio from existing clips and rebuild the "
"final video without any TTS generation.")
parser.add_argument("--regen", nargs="+", type=int, metavar="N",
help="Regenerate specific batch clip(s) by batch number "
"(as shown during generation and in batches.json). "
"Run --rebuild afterwards to reassemble the video.")
args = parser.parse_args()
COMFY_URL = args.comfy_url
out_dir = Path(args.output_dir)
out_dir.mkdir(parents=True, exist_ok=True)
clips_dir = out_dir / "clips"
clips_dir.mkdir(exist_ok=True)
# ── Rebuild mode ──────────────────────────────────────────────────────────
if args.rebuild:
_rebuild_video(args, out_dir)
return
# ── Regen mode ────────────────────────────────────────────────────────────
if args.regen:
_regen_clips(args, out_dir)
return
start_sec = _parse_mmss(args.start)
end_sec = _parse_mmss(args.end) if args.end else None
# ── Step 1: Parse subtitles ───────────────────────────────────────────────
print(f"\n[1/4] Parsing subtitles {args.start}–{args.end or 'end'}…")
if end_sec is None:
end_sec = 99 * 3600
blocks = parse_subtitle_blocks(args.srt, start_sec, end_sec)
if not blocks:
sys.exit("No subtitle blocks found in the specified range.")
blocks = split_dialogue_blocks(blocks)
n_batches = (len(blocks) + args.batch_size - 1) // args.batch_size
print(f" {len(blocks)} subtitle blocks → {n_batches} batches "
f"of up to {args.batch_size}.")
srt_preview = out_dir / "tts_input.srt"
srt_preview.write_text(blocks_to_srt(blocks), encoding="utf-8")
print(f" Saved individual blocks to {srt_preview} for reference.")
# ── Step 2: Generate TTS per batch ────────────────────────────────────────
if args.skip_tts:
assembled_tts = Path(args.skip_tts)
print(f"\n[2/4] Using existing TTS audio: {assembled_tts}")
else:
print(f"\n[2/4] Generating TTS ({n_batches} batches × up to "
f"{args.batch_size} blocks, speed {args.speed}×)…")
wav_path = Path(args.voice)
if not wav_path.is_file():
wav_path = VOICE_PROMPTS_DIR / f"{args.voice}.wav"
if not wav_path.is_file():
sys.exit(f"Voice WAV not found: {wav_path}")
print(f" Voice: {wav_path.name}")
base_seed = args.seed if args.seed != 0 else random.randint(1, 2**31 - 1)
print(f" Base seed: {base_seed}")
voice_filename, voice_ref_text = upload_voice_wav(wav_path)
clips, batches = generate_batch_clips(
blocks, args.batch_size,
voice_filename, voice_ref_text,
base_seed, args.speed, clips_dir,
strip_leading_ms=args.strip_leading_ms,
resume_from=args.resume_from,
temperature=args.temperature,
trailing_fade_ms=args.trailing_fade_ms,
max_new_tokens=args.max_tokens,
)
batches_json = out_dir / "batches.json"
batches_json.write_text(
json.dumps(batches, ensure_ascii=False, indent=2),
encoding="utf-8",
)
print(f"\n Batch map saved to {batches_json}")
assembled_tts = out_dir / "tts_assembled.flac"
assemble_timed_audio(clips, str(assembled_tts))
print(f" Assembled: {assembled_tts} ({assembled_tts.stat().st_size // 1024} KB)")
print(f" Clips saved to {clips_dir}")
# ── Step 3: Extract video fragment ───────────────────────────────────────
print(f"\n[3/4] Extracting video fragment…")
real_end = _parse_mmss(args.end) if args.end else None
if real_end is not None:
video_fragment = out_dir / "video_fragment.mkv"
extract_video_fragment(args.video, start_sec, real_end, str(video_fragment))
else:
video_fragment = Path(args.video)
# ── Step 4: Mix voiceover with video ─────────────────────────────────────
stem = Path(args.video).stem
final_output = out_dir / f"{stem}_lektor.mp4"
print(f"\n[4/4] Building 2-track video (Original + Polish lektor "
f"TTS {int(args.tts_volume * 100)}% / orig {int(args.orig_volume * 100)}%)…")
merge_voiceover(str(video_fragment), str(assembled_tts), str(final_output),
orig_volume=args.orig_volume, tts_volume=args.tts_volume)
print(f"\nDone! Output: {final_output}")
if __name__ == "__main__":
main()
+216
View File
@@ -0,0 +1,216 @@
lektor_ai_comfy — Polish AI Dubbing Tool
=========================================
FULL DOCUMENTATION: see docs/
docs/README.md start here — overview and quick start
docs/requirements.md hardware, software, dependencies
docs/installation.md installing ComfyUI + the TTS engine
docs/usage.md every CLI flag, working modes, recipes
docs/architecture.md how the pipeline works internally
docs/troubleshooting.md diagnosed failure modes and their fixes
Installer: ./install_comfyui.sh (see docs/installation.md)
This file remains as a condensed quick reference.
Converts SRT subtitles to AI voiceover (lektor style) using ComfyUI + Higgs Audio V3 TTS,
then mixes the result with the original video audio into a two-track MKV.
All subtitle tracks in the source video are carried over to the output.
Requirements
------------
- ComfyUI running at http://127.0.0.1:8189 with TTS-Audio-Suite custom node
- Model: higgs-audio-v3-tts-4b (bosonai/higgs-audio-v3)
- ffmpeg and ffprobe available on PATH
- Voice reference: a WAV file + matching .txt transcript in the voice directory
(default: gudowski/gudowski.wav + gudowski/gudowski.txt)
Basic Usage
-----------
Full run with an external SRT file:
python3 lektor_ai_comfy.py input/film.srt input/film.mkv
Use subtitles embedded in the video (lists tracks, prompts for a choice):
python3 lektor_ai_comfy.py input/film.mkv --embedded-subs
Process only a time range (extracts a video fragment automatically):
python3 lektor_ai_comfy.py input/film.srt input/film.mkv --start 01:20 --end 05:40
Subtitle Handling
-----------------
Both external SRT files and embedded subtitle tracks go through the same cleaning:
- HTML tags stripped: <i>, </i>, <b>, <font color="…">, etc.
- ASS override codes stripped: {\an8}, {\pos(x,y)}, {\c&H…&}, etc.
- ALL-CAPS blocks lowercased and converted to sentence case
- Sentence fragments starting with a lowercase letter are capitalised
- Missing terminal punctuation added (… or . depending on context)
- Dialogue lines (prefixed with -) are split into separate clips with proportional timing
With --embedded-subs the extracted subtitles are saved to output/embedded_sub.srt
so you can inspect them before or after the run.
The final MKV preserves all subtitle tracks from the source video — text-based
(SRT, ASS, SSA, WebVTT) and image-based (PGS Blu-ray, VOBSUB) alike — using
stream copy with no conversion.
Output Files
------------
output/
tts_input.srt — processed subtitle text sent to TTS (inspect to verify)
embedded_sub.srt — extracted embedded subtitle (only when --embedded-subs)
clips/clip_NNNN.flac — individual TTS clips, one per subtitle block (lossless)
tts_assembled.flac — all clips assembled with correct timing (lossless)
video_fragment.mkv — extracted video segment (only when --end is used)
{stem}_lektor.mkv — final video with two audio tracks + all original subtitles:
Track 0 — Original (source audio, stream-copied as-is)
Track 1 — Polish lektor (TTS + original mixed,
AAC 192k stereo)
All Options
-----------
srt Input SRT subtitle file (omit when using --embedded-subs)
video Input video file (MKV, MP4, ...)
--embedded-subs Use a subtitle track embedded in the video instead of an
external SRT file. The script lists all subtitle tracks
found in the video and prompts you to choose one.
The video path can be given as the only positional argument:
python3 lektor_ai_comfy.py film.mkv --embedded-subs
--start MM:SS Start time of the range to process (default: 00:00)
--end MM:SS End time of the range to process (default: end of file)
--voice NAME|PATH Voice prompt: name of a subdirectory under the voice prompts
directory, or a direct path to a WAV file.
The directory must contain a .wav and matching .txt transcript.
(default: gudowski_short)
--speed FACTOR Per-clip playback speed via atempo (default: 1.2)
Values above 1.0 speed up speech; 1.0 = no change.
--seed N Base generation seed. 0 = random each run. (default: 0)
A fixed seed produces deterministic output.
--temperature T TTS sampling temperature (default: 0.9)
Lower = more deterministic; higher = more expressive but
risks hallucination (1.0 can cause the model to recite
the reference text instead of the subtitle).
--max-tokens N Max audio tokens the model may generate per clip (default: 4096)
Raise this if long clips are cut off mid-word.
--orig-volume 0-1 Volume of the original audio in the Polish lektor mix (default: 0.60)
--tts-volume 0-2 Volume of the TTS voiceover in the Polish lektor mix (default: 1.8)
Note: these affect Track 1 only. Track 0 is always the raw original.
--strip-leading-ms MS Strip N milliseconds from the start of each clip to remove
leading breath or noise artifacts (default: 0, try 80-150)
--trailing-fade-ms MS Fade out the last N milliseconds of each clip to suppress
tail pops or noise (default: 80, set 0 to disable)
--output-dir DIR Output directory (default: output/)
--comfy-url URL ComfyUI server URL (default: http://127.0.0.1:8189)
--skip-tts AUDIO Skip TTS generation entirely and use this pre-assembled
audio file instead. Goes straight to video merge.
Recovery and Fixing Bad Clips
------------------------------
Resume a crashed run from block N (reuses existing clips 1..N-1):
python3 lektor_ai_comfy.py input/film.srt input/film.mkv --resume-from 47
Regenerate specific clips by block number (same numbering as tts_input.srt):
python3 lektor_ai_comfy.py input/film.mkv --regen 12 22 41
Only replaces the selected clips/clip_NNNN.flac files — does not reassemble
or rebuild the video. Run --rebuild afterwards to apply the changes.
Useful for fixing hallucinations, mumbling, or cut-off clips.
Reassemble audio from all existing clips and rebuild the final video
(no TTS generation — use this after --regen, or to adjust volumes):
python3 lektor_ai_comfy.py input/film.mkv --rebuild
python3 lektor_ai_comfy.py input/film.mkv --rebuild --orig-volume 0.5 --tts-volume 2.0
For --rebuild and --regen the SRT file is not needed — just pass the video path.
When --end is given, rebuilds from the same video fragment as the original run
(re-extracts it if needed). Without --end, uses the full video file.
Tips
----
- Check output/tts_input.srt before or after a run to see the exact text sent to TTS.
Block numbers in that file match the clip_NNNN.flac filenames.
- If a clip sounds wrong (hallucination, wrong language, cut off), note its block
number from tts_input.srt and use --regen N to replace just that clip,
then run --rebuild to reassemble the video.
- If ComfyUI crashes mid-run, note the last completed block number and use
--resume-from N to continue without regenerating finished clips.
- The final MP4 has two audio tracks. Most players let you switch between them:
Track 0 — Original soundtrack
Track 1 — Polish lektor (AI voiceover mixed with quiet original)
- Track 0 (Original) is stream-copied without re-encoding, so source quality is preserved.
The MKV container supports all common audio codecs (AAC, AC3, EAC3, DTS, TrueHD),
so no workarounds are needed for the original audio track.
- Videos with audio starting at a non-zero PTS offset (common in MKV rips) are handled
automatically — the original audio background in the lektor mix is normalised so it
stays in sync with the video.
- The lektor track (Track 1) is stereo by design, while Track 0 keeps the original
5.1 untouched. ffmpeg's AAC encoder does not signal a 5.1 channel layout — it ends up
tagged "unknown" in every container, and players that cannot route the channels play
the track as silence. Stereo is tagged correctly and plays everywhere. It also spends
the 192k on two channels instead of spreading it thinly across six. The 5.1 original
is downmixed with ffmpeg's standard coefficients and the mono TTS is centred.
- The final video is built in two passes: first the lektor audio mix is rendered to a
temporary file, then everything is stream-copied into the MKV. This is deliberate.
Doing both at once fails on sources with many subtitle tracks (some releases carry
40+) — the sparse subtitle streams stall the muxer while the audio filter keeps
producing, and ffmpeg aborts with "Failed to inject frame into filter network".
The two-pass approach also makes the final mux about 10x faster.
- The final mux passes -max_interleave_delta 0 as an OUTPUT option (it must come after
the maps and before the output path; placed before -i it configures the demuxer and
silently does nothing). Without it, sparse subtitle tracks make the muxer abandon
interleaving after its default 10-second window, and the lektor audio ends up written
hundreds of megabytes away from the video it belongs with. The file measures perfectly
fine with ffmpeg, but players read MKV sequentially and never reach that audio, so the
Polish track plays as complete silence. Setting it to 0 buffers until every stream has
a packet, costing a couple of GB of transient RAM for a few seconds.
- If a lektor track ever plays silent again, check interleaving before suspecting the
audio. Compare byte positions at the same timestamp:
ffprobe -read_intervals 00:20:00%+1 -select_streams v -show_entries packet=pos file.mkv
ffprobe -read_intervals 00:20:00%+1 -select_streams a:1 -show_entries packet=pos file.mkv
They should be within a few MB of each other. An existing file can be repaired without
regenerating anything:
ffmpeg -i in.mkv -map 0 -c copy -max_interleave_delta 0 out.mkv
- ComfyUI crash recovery:
sudo fuser -k /dev/nvidia*
sudo nvidia-smi --gpu-reset
+10
View File
@@ -0,0 +1,10 @@
# Dependencies for lektor_ai.py
openai>=1.0.0
# System dependency: ffmpeg must be on PATH.
# Voice prompts (gudowski.wav etc.) live in:
# /home/lukas/higgs-audio/examples/voice_prompts/
#
# Boson AI API key required (free tier):
# export BOSON_API_KEY=bai-xxxx
# Get one at https://boson.ai/workspace
+4
View File
@@ -0,0 +1,4 @@
#!/bin/bash
source ~/miniforge3/etc/profile.d/conda.sh
conda activate comfy-tts
cd ~/comfy-tts-app/ComfyUI && python main.py --port 8189