Survive a ComfyUI crash, retry clips the model cuts short

Crash recovery
  ComfyUI's queue and history do not persist across a restart, so a prompt
  submitted before a crash exists nowhere afterwards. The old poll loop waited
  on that dead prompt for ever, and its error branch skipped the backoff, so it
  hammered the closed port four times a second printing "Poll error" on every
  attempt.

  run_workflow() replaces wait_for_completion(). It reports once when the
  backend goes away and once when it returns, polls every 3s in between, and on
  reconnect checks both /history and /queue: if the prompt is in neither, the
  queue did not survive and the clip is submitted again. When it cannot tell, it
  assumes the job is still alive — resubmitting a running job would waste a
  generation.

  It also starts ComfyUI itself, via the script named by [comfy] start_command,
  in a new terminal window. Guarded by a 90s cooldown and 3 attempts per run so
  a backend that dies during startup cannot spawn an endless stream of windows;
  the window pauses on exit so a failed start leaves its error on screen. Falls
  back to a background launch with a log file when there is no display. Set
  [comfy] autostart = no, or pass --no-comfy-autostart, to turn it off.

Clips cut short mid-word
  About one clip in twelve comes back truncated: the model stops before the line
  is finished. Measured on raw ComfyUI output, 6 of 80 recent clips, and 15 of
  472 in one episode. It is invisible in the text but obvious in the audio — a
  finished clip decays to about -65 dB, a cut one stops at about -16 dB.

  Truncated clips are now regenerated with a distant seed, up to
  [tts] truncation_retries extra attempts, and a run that still has bad clips
  ends by printing them as a ready-made --regen list. --check-clips reports the
  same for a job already on disk.

  Worth recording what this is NOT: the trailing-silence trimming added earlier
  was suspected, and cleared. It only cuts silence that runs to end of file, so
  a pause between words never triggers it — verified against six synthetic cases
  including a 1.2s mid-clip pause, and against real clips containing pauses. The
  truncation is present in ComfyUI's own output before this script touches it.

settings.ini
  New: [tts] truncation_retries, [comfy] autostart and start_command, all
  documented in place. The editor renders yes/no keys as a dropdown.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 14:12:33 +02:00
co-authored by Claude Opus 5
parent 200d221816
commit 7e2cee02e5
3 changed files with 298 additions and 33 deletions
+275 -32
View File
@@ -11,8 +11,10 @@ Requires ComfyUI with TTS-Audio-Suite running at http://127.0.0.1:8189
import argparse import argparse
import configparser import configparser
import json import json
import os
import random import random
import re import re
import shlex
import shutil import shutil
import subprocess import subprocess
import sys import sys
@@ -32,6 +34,17 @@ VOICE_PROMPTS_DIR = Path(__file__).resolve().parent / "voices"
SETTINGS_FILE = Path(__file__).resolve().parent / "settings.ini" SETTINGS_FILE = Path(__file__).resolve().parent / "settings.ini"
def _as_bool(raw) -> bool:
"""yes/no setting. configparser's own getboolean is bypassed by the
per-key cast in load_settings, so accept the same spellings here."""
value = str(raw).strip().lower()
if value in ("1", "true", "yes", "on"):
return True
if value in ("0", "false", "no", "off"):
return False
raise ValueError(f"{raw!r} is not a yes/no value")
# Fallback values, used when settings.ini is absent or omits a key. Keeping them # Fallback values, used when settings.ini is absent or omits a key. Keeping them
# here means the script still runs correctly with no settings file at all. # here means the script still runs correctly with no settings file at all.
# Each entry is (ini_section, ini_key, type, value). # Each entry is (ini_section, ini_key, type, value).
@@ -40,16 +53,23 @@ _DEFAULTS = {
"speed": ("tts", "speed", float, 1.2), "speed": ("tts", "speed", float, 1.2),
"temperature": ("tts", "temperature", float, 0.9), "temperature": ("tts", "temperature", float, 0.9),
"max_tokens": ("tts", "max_tokens", int, 4096), "max_tokens": ("tts", "max_tokens", int, 4096),
"truncation_retries": ("tts", "truncation_retries", int, 2),
"strip_leading_ms": ("tts", "strip_leading_ms", float, 0.0), "strip_leading_ms": ("tts", "strip_leading_ms", float, 0.0),
"trailing_fade_ms": ("tts", "trailing_fade_ms", float, 80.0), "trailing_fade_ms": ("tts", "trailing_fade_ms", float, 80.0),
"orig_volume": ("mix", "orig_volume", float, 0.60), "orig_volume": ("mix", "orig_volume", float, 0.60),
"tts_volume": ("mix", "tts_volume", float, 1.8), "tts_volume": ("mix", "tts_volume", float, 1.8),
"output_dir": ("paths", "output_dir", str, "output"), "output_dir": ("paths", "output_dir", str, "output"),
"comfy_url": ("comfy", "comfy_url", str, "http://127.0.0.1:8189"), "comfy_url": ("comfy", "comfy_url", str, "http://127.0.0.1:8189"),
"comfy_autostart": ("comfy", "autostart", _as_bool, True),
"comfy_start_command": ("comfy", "start_command", str, "start_comfyui.sh"),
} }
COMFY_URL = _DEFAULTS["comfy_url"][3] # rebound from settings/CLI in main() COMFY_URL = _DEFAULTS["comfy_url"][3] # rebound from settings/CLI in main()
# Rebound in main() too; used when the backend stops answering.
COMFY_AUTOSTART = True
COMFY_START_SCRIPT = Path(__file__).resolve().parent / "start_comfyui.sh"
def load_settings(path: Path) -> dict: def load_settings(path: Path) -> dict:
"""Read settings.ini, falling back to _DEFAULTS for anything missing. """Read settings.ini, falling back to _DEFAULTS for anything missing.
@@ -416,6 +436,31 @@ FLAT_TAIL_MIN = 5.0 # the flat run must last at least this long
RUNAWAY_SECONDS = 20.0 # only look for tone padding on clips this long RUNAWAY_SECONDS = 20.0 # only look for tone padding on clips this long
# A clip the model finished decays to near silence; one it cut off mid-word
# stops at full level. Measured across 80 raw outputs the two groups sit about
# 50 dB apart (-65 dB vs -16 dB), so the test is unambiguous.
TRUNCATION_PEAK_DB = -25.0
TRUNCATION_WINDOW = 0.08
def ends_abruptly(path: Path, window: float = TRUNCATION_WINDOW) -> bool:
"""True when a clip stops at full level, i.e. the model cut it mid-word."""
try:
duration = get_audio_duration(path)
except RuntimeError:
return False
if duration <= window:
return False
result = subprocess.run(
["ffmpeg", "-hide_banner", "-ss", f"{duration - window:.3f}",
"-t", f"{window}", "-i", str(path),
"-af", "volumedetect", "-f", "null", "-"],
capture_output=True, text=True,
)
peak = re.search(r"max_volume:\s*(-?[0-9.]+)", result.stderr)
return bool(peak) and float(peak.group(1)) > TRUNCATION_PEAK_DB
def flat_tail_start(path: Path, window: float = 0.5) -> float: def flat_tail_start(path: Path, window: float = 0.5) -> float:
"""Start of a long, unnaturally constant tail, or None.""" """Start of a long, unnaturally constant tail, or None."""
result = subprocess.run( result = subprocess.run(
@@ -781,33 +826,148 @@ def submit_workflow(workflow: dict) -> str:
return resp["prompt_id"] return resp["prompt_id"]
def wait_for_completion(prompt_id: str, poll_interval: float = 0.25, BACKEND_RETRY_SECONDS = 3.0 # how often to look for ComfyUI while it is down
max_interval: float = 2.0, backoff_after: float = 10.0) -> dict:
"""Block until ComfyUI reports the job finished, then return its history entry.
The poll interval, not the GPU, governs perceived generation speed: a clip
that finishes 100 ms after a poll still waits for the next one. This used to
be a flat 3 s, which quantised every clip to a 3 s boundary — measured across
2075 real clips, 91% of the gaps landed exactly on a multiple of 3 s, and
roughly a third of each run was spent sleeping on already-completed work.
Polling starts tight so the common case (most clips finish in 3-6 s) is # Launching the backend is guarded: a ComfyUI that dies on startup would
picked up almost immediately, then backs off once a job looks slow so a # otherwise spawn a new terminal window every few seconds.
stuck or very long generation does not sit at 4 requests/second forever. COMFY_AUTOSTART_MAX = 3 # attempts per run
COMFY_AUTOSTART_COOLDOWN = 90.0 # seconds between attempts
_autostart = {"attempts": 0, "last": 0.0}
_TERMINALS = (
("gnome-terminal", lambda c: ["gnome-terminal", "--title", "ComfyUI", "--",
"bash", "-lc", c]),
("konsole", lambda c: ["konsole", "-p", "tabtitle=ComfyUI", "-e",
"bash", "-lc", c]),
("xfce4-terminal", lambda c: ["xfce4-terminal", "--title", "ComfyUI",
"-e", f"bash -lc {shlex.quote(c)}"]),
("xterm", lambda c: ["xterm", "-T", "ComfyUI", "-e", "bash", "-lc", c]),
)
def start_comfy_backend(script: Path) -> bool:
"""Launch ComfyUI in its own terminal window. True if one was started."""
now = time.monotonic()
if _autostart["attempts"] >= COMFY_AUTOSTART_MAX:
return False
if _autostart["last"] and now - _autostart["last"] < COMFY_AUTOSTART_COOLDOWN:
return False
if not script.is_file():
print(f" Cannot start ComfyUI automatically: {script} not found.",
file=sys.stderr)
_autostart["attempts"] = COMFY_AUTOSTART_MAX # do not keep complaining
return False
_autostart["attempts"] += 1
_autostart["last"] = now
# Pause on exit so a backend that fails to start leaves its error on screen
# instead of the window vanishing.
inner = (f"{shlex.quote(str(script))}; echo; "
f"echo '[ComfyUI exited — press any key to close]'; read -n 1")
if os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"):
for name, build in _TERMINALS:
if not shutil.which(name):
continue
try:
subprocess.Popen(build(inner), start_new_session=True)
except OSError:
continue
print(f" Starting ComfyUI in a new {name} window "
f"(attempt {_autostart['attempts']} of {COMFY_AUTOSTART_MAX})…",
file=sys.stderr)
return True
log = script.parent / "comfyui_autostart.log"
try:
with open(log, "ab") as handle:
subprocess.Popen([str(script)], stdout=handle, stderr=handle,
start_new_session=True)
except OSError as exc:
print(f" Could not start ComfyUI: {exc}", file=sys.stderr)
return False
print(f" No terminal available — started ComfyUI in the background, "
f"logging to {log}", file=sys.stderr)
return True
def _prompt_known(prompt_id: str) -> bool:
"""True while ComfyUI still has this prompt queued, running or in history.
A restart wipes both, so this is how a job lost to a crash is spotted.
If the answer cannot be determined, say yes: resubmitting a job that is
actually still running would waste a generation.
""" """
print(f" Waiting for {prompt_id[:8]}…", end="", flush=True) try:
start = time.monotonic() if prompt_id in _comfy_get(f"/history/{prompt_id}"):
interval = poll_interval return True
next_dot = 1.0 queue = _comfy_get("/queue")
except Exception: # noqa: BLE001
return True
for key in ("queue_running", "queue_pending"):
for item in queue.get(key, []):
if isinstance(item, (list, tuple)) and len(item) > 1 and item[1] == prompt_id:
return True
return False
def run_workflow(workflow: dict, poll_interval: float = 0.25,
max_interval: float = 2.0, backoff_after: float = 10.0) -> dict:
"""Submit a workflow, wait for it, and return its history entry.
Survives ComfyUI dying mid-job. Its queue and history do not persist across
a restart, so a prompt submitted beforehand exists nowhere afterwards and
polling it would wait for ever — the job has to be sent again. While the
backend is down this reports once rather than once per poll, and slows to
BACKEND_RETRY_SECONDS instead of hammering a closed port.
The poll interval, not the GPU, governs perceived speed: a clip that
finishes 100 ms after a poll still waits for the next one. It was a flat
3 s, which quantised every clip to a 3 s boundary — across 2075 real clips
91% of the gaps landed exactly on a multiple of 3 s. So polling starts
tight and only backs off once a job looks slow.
"""
prompt_id, offline = None, False
start, interval, next_dot = time.monotonic(), poll_interval, 1.0
while True: while True:
if prompt_id is None: # first submission, or a resubmit
try:
prompt_id = submit_workflow(workflow)
except Exception as exc: # noqa: BLE001
if not offline:
print(f"\n ComfyUI unreachable ({exc}).", file=sys.stderr)
offline = True
if not (COMFY_AUTOSTART and start_comfy_backend(COMFY_START_SCRIPT)):
print(" Waiting for it to come back — start it and this "
"will carry on.", file=sys.stderr)
time.sleep(BACKEND_RETRY_SECONDS)
continue
start, interval, next_dot = time.monotonic(), poll_interval, 1.0
print(f" Waiting for {prompt_id[:8]}…", end="", flush=True)
try: try:
history = _comfy_get(f"/history/{prompt_id}") history = _comfy_get(f"/history/{prompt_id}")
except Exception as e: except Exception as exc: # noqa: BLE001
print(f"\n Poll error: {e}", file=sys.stderr) if not offline:
time.sleep(min(interval, 1.0)) print(f"\n ComfyUI went away ({exc}).", file=sys.stderr)
offline = True
if not (COMFY_AUTOSTART and start_comfy_backend(COMFY_START_SCRIPT)):
print(" Waiting for it to come back — restart it and this "
"will carry on.", file=sys.stderr)
time.sleep(BACKEND_RETRY_SECONDS)
continue continue
if offline: # it answered again
offline = False
print(" ComfyUI is back.", file=sys.stderr)
if not _prompt_known(prompt_id):
print(" The queue did not survive the restart — resubmitting "
"this clip.", file=sys.stderr)
prompt_id = None
continue
if prompt_id in history: if prompt_id in history:
entry = history[prompt_id] entry = history[prompt_id]
status = entry.get("status", {}) status = entry.get("status", {})
@@ -843,13 +1003,33 @@ def extract_output_audio(history_entry: dict) -> Path:
def _generate_one(text: str, voice_filename: str, voice_ref_text: str, def _generate_one(text: str, voice_filename: str, voice_ref_text: str,
seed: int, speed: float, out_dir: Path, label: str, seed: int, speed: float, out_dir: Path, label: str,
strip_leading_ms: float, temperature: float = 0.9, strip_leading_ms: float, temperature: float = 0.9,
trailing_fade_ms: float = 0, max_new_tokens: int = 4096) -> tuple: trailing_fade_ms: float = 0, max_new_tokens: int = 4096,
"""Submit one TTS job and return (path, duration_after_speedup).""" retries: int = 2) -> tuple:
workflow = build_text_workflow(text, voice_filename, voice_ref_text, seed, """Submit one TTS job and return (path, duration_after_speedup, truncated).
temperature, max_new_tokens)
prompt_id = submit_workflow(workflow) About one clip in twelve comes back cut off mid-word — the model stops
history_entry = wait_for_completion(prompt_id) generating before the line is finished. It is not predictable from the
raw_clip = extract_output_audio(history_entry) text, but it is obvious in the audio, so a clip that ends at full level is
simply asked for again with a different seed.
"""
attempt, truncated = 0, False
while True:
workflow = build_text_workflow(text, voice_filename, voice_ref_text, seed,
temperature, max_new_tokens)
raw_clip = extract_output_audio(run_workflow(workflow))
truncated = ends_abruptly(raw_clip)
if not truncated or attempt >= retries:
break
attempt += 1
seed += 7919 # a distant seed, not the neighbouring sample
print(f" ! ends mid-word — retrying with seed {seed} "
f"(attempt {attempt + 1} of {retries + 1})")
if truncated:
print(" ! still ends mid-word after "
f"{attempt + 1} attempt{'s' if attempt else ''} — "
"regenerate this one with --regen")
if strip_leading_ms > 0: if strip_leading_ms > 0:
stripped = out_dir / f"{label}_s.flac" stripped = out_dir / f"{label}_s.flac"
@@ -873,14 +1053,14 @@ def _generate_one(text: str, voice_filename: str, voice_ref_text: str,
speed_up_audio(raw_clip, fast_clip, speed, fade_out_ms=trailing_fade_ms) speed_up_audio(raw_clip, fast_clip, speed, fade_out_ms=trailing_fade_ms)
trimmed.unlink(missing_ok=True) trimmed.unlink(missing_ok=True)
return fast_clip, get_audio_duration(fast_clip) return fast_clip, get_audio_duration(fast_clip), truncated
def generate_clips(blocks: list, voice_filename: str, voice_ref_text: str, def generate_clips(blocks: list, voice_filename: str, voice_ref_text: str,
base_seed: int, speed: float, clips_dir: Path, base_seed: int, speed: float, clips_dir: Path,
strip_leading_ms: float = 0.0, resume_from: int = 0, strip_leading_ms: float = 0.0, resume_from: int = 0,
temperature: float = 0.9, trailing_fade_ms: float = 0, temperature: float = 0.9, trailing_fade_ms: float = 0,
max_new_tokens: int = 4096) -> list: max_new_tokens: int = 4096, retries: int = 2) -> list:
""" """
Generate one audio clip per block via ComfyUI, apply per-clip speed-up, Generate one audio clip per block via ComfyUI, apply per-clip speed-up,
then calculate start positions with "push forward if overlap" logic. then calculate start positions with "push forward if overlap" logic.
@@ -893,6 +1073,7 @@ def generate_clips(blocks: list, voice_filename: str, voice_ref_text: str,
clips = [] clips = []
cursor = 0.0 cursor = 0.0
total = len(blocks) total = len(blocks)
still_truncated = []
for idx, (t_start, t_end, text) in enumerate(blocks): for idx, (t_start, t_end, text) in enumerate(blocks):
clip_n = idx + 1 clip_n = idx + 1
@@ -914,11 +1095,14 @@ def generate_clips(blocks: list, voice_filename: str, voice_ref_text: str,
print(f" [{clip_n}/{total}] {_fmt_srt_time(t_start)} {display[:60]!r}") print(f" [{clip_n}/{total}] {_fmt_srt_time(t_start)} {display[:60]!r}")
seed = base_seed + idx seed = base_seed + idx
fast_clip, duration = _generate_one( fast_clip, duration, truncated = _generate_one(
text, voice_filename, voice_ref_text, text, voice_filename, voice_ref_text,
seed, speed, clips_dir, label, strip_leading_ms, temperature, seed, speed, clips_dir, label, strip_leading_ms, temperature,
trailing_fade_ms=trailing_fade_ms, max_new_tokens=max_new_tokens, trailing_fade_ms=trailing_fade_ms, max_new_tokens=max_new_tokens,
retries=retries,
) )
if truncated:
still_truncated.append(clip_n)
# Place at SRT time or after previous clip, whichever is later. # Place at SRT time or after previous clip, whichever is later.
# No trimming — clips play to their natural end; re-align at the next # No trimming — clips play to their natural end; re-align at the next
@@ -931,6 +1115,11 @@ def generate_clips(blocks: list, voice_filename: str, voice_ref_text: str,
delay_str = f" [+{delay:.2f}s behind SRT]" if delay > 0.05 else "" delay_str = f" [+{delay:.2f}s behind SRT]" if delay > 0.05 else ""
print(f" → placed at {_fmt_srt_time(actual_start)}, duration {duration:.2f}s{delay_str}") print(f" → placed at {_fmt_srt_time(actual_start)}, duration {duration:.2f}s{delay_str}")
if still_truncated:
print(f"\n {len(still_truncated)} clip(s) still end mid-word after retries. "
f"To try again:")
print(f" --regen {' '.join(str(n) for n in still_truncated)}")
return clips return clips
@@ -1023,6 +1212,37 @@ def _rebuild_video(args, out_dir: Path):
print(f"\nDone! Output: {final_output}") print(f"\nDone! Output: {final_output}")
def _check_job_clips(args, out_dir: Path):
"""List clips in this video's job folder that end mid-word.
Prints them as a --regen argument list, so fixing them is a copy and paste.
"""
job = job_dir(out_dir, args.video)
clips = sorted(job.glob("clip_*.flac"))
if not clips:
sys.exit(f"No clips found in {job}." + _legacy_hint(out_dir, job))
print(f"\n Checking {len(clips)} clips in {job}…")
bad = []
for clip in clips:
if ends_abruptly(clip):
try:
number = int(clip.stem.split("_")[1])
except (IndexError, ValueError):
continue
bad.append(number)
print(f" {clip.name} ends at full level "
f"({get_audio_duration(clip):.2f}s)")
if not bad:
print(f"\n No truncated clips. All {len(clips)} end cleanly.")
return
print(f"\n {len(bad)} of {len(clips)} clips ({100 * len(bad) / len(clips):.0f}%) "
f"end mid-word. To regenerate them:")
print(f" python3 {Path(__file__).name} \"{args.video}\" --regen "
f"{' '.join(str(n) for n in bad)}")
def _trim_job_clips(args, out_dir: Path): def _trim_job_clips(args, out_dir: Path):
"""Trim trailing silence from every clip already on disk for this video. """Trim trailing silence from every clip already on disk for this video.
@@ -1100,12 +1320,13 @@ def _regen_clips(args, out_dir: Path):
label = f"clip_{clip_n:04d}" label = f"clip_{clip_n:04d}"
print(f"\n REGEN [{clip_n}/{total}] {text[:70]!r}") print(f"\n REGEN [{clip_n}/{total}] {text[:70]!r}")
seed = base_seed + idx seed = base_seed + idx
clip_path, duration = _generate_one( clip_path, duration, truncated = _generate_one(
text, voice_filename, voice_ref_text, text, voice_filename, voice_ref_text,
seed, args.speed, clips_dir, label, seed, args.speed, clips_dir, label,
args.strip_leading_ms, args.temperature, args.strip_leading_ms, args.temperature,
trailing_fade_ms=args.trailing_fade_ms, trailing_fade_ms=args.trailing_fade_ms,
max_new_tokens=args.max_tokens, max_new_tokens=args.max_tokens,
retries=args.truncation_retries,
) )
print(f" → saved as {clip_path.name} ({duration:.2f}s)") print(f" → saved as {clip_path.name} ({duration:.2f}s)")
@@ -1117,7 +1338,7 @@ def _regen_clips(args, out_dir: Path):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def main(): def main():
global COMFY_URL global COMFY_URL, COMFY_AUTOSTART, COMFY_START_SCRIPT
# --settings is resolved before the main parser is built, because the file's # --settings is resolved before the main parser is built, because the file's
# contents become that parser's defaults. # contents become that parser's defaults.
@@ -1188,6 +1409,18 @@ def main():
"(same number as in clips/clip_NNNN.flac and tts_input.srt). " "(same number as in clips/clip_NNNN.flac and tts_input.srt). "
"Only replaces the selected FLAC files; run --rebuild " "Only replaces the selected FLAC files; run --rebuild "
"afterwards to reassemble audio and rebuild the video.") "afterwards to reassemble audio and rebuild the video.")
parser.add_argument("--no-comfy-autostart", action="store_true",
help="Do not launch ComfyUI automatically when it is not "
"answering. By default it is started via the script named "
"by [comfy] start_command in settings.ini.")
parser.add_argument("--truncation-retries", type=int,
default=cfg["truncation_retries"], metavar="N",
help=f"How many extra attempts to make when the model returns a "
f"clip cut off mid-word (default: {cfg['truncation_retries']}; "
f"0 disables retrying).")
parser.add_argument("--check-clips", action="store_true",
help="Report which clips in this video's job folder end mid-word, "
"as a ready-made --regen list, then stop.")
parser.add_argument("--trim-silence", action="store_true", parser.add_argument("--trim-silence", action="store_true",
help="Trim trailing silence from the clips already generated for " help="Trim trailing silence from the clips already generated for "
"this video, then stop. Repairs runs where the model failed " "this video, then stop. Repairs runs where the model failed "
@@ -1204,6 +1437,10 @@ def main():
args = parser.parse_args() args = parser.parse_args()
COMFY_URL = args.comfy_url COMFY_URL = args.comfy_url
COMFY_AUTOSTART = cfg["comfy_autostart"] and not args.no_comfy_autostart
start_cmd = Path(cfg["comfy_start_command"]).expanduser()
COMFY_START_SCRIPT = start_cmd if start_cmd.is_absolute() \
else Path(__file__).resolve().parent / start_cmd
if settings_path.is_file(): if settings_path.is_file():
print(f" Settings: {settings_path}") print(f" Settings: {settings_path}")
@@ -1215,7 +1452,7 @@ def main():
# --regen without an SRT), argparse puts the lone positional into args.srt. # --regen without an SRT), argparse puts the lone positional into args.srt.
# Detect this and move it to args.video. # Detect this and move it to args.video.
needs_srt = not (args.embedded_subs or args.rebuild or args.trim_silence needs_srt = not (args.embedded_subs or args.rebuild or args.trim_silence
or bool(args.regen)) or args.check_clips or bool(args.regen))
if args.video is None and args.srt is not None and not needs_srt: if args.video is None and args.srt is not None and not needs_srt:
args.video = args.srt args.video = args.srt
args.srt = None args.srt = None
@@ -1228,6 +1465,11 @@ def main():
out_dir = Path(args.output_dir) out_dir = Path(args.output_dir)
out_dir.mkdir(parents=True, exist_ok=True) out_dir.mkdir(parents=True, exist_ok=True)
# ── Maintenance: report clips the model cut short, then stop ─────────────
if args.check_clips:
_check_job_clips(args, out_dir)
return
# ── Maintenance: trim silence from existing clips, then stop ───────────── # ── Maintenance: trim silence from existing clips, then stop ─────────────
if args.trim_silence: if args.trim_silence:
_trim_job_clips(args, out_dir) _trim_job_clips(args, out_dir)
@@ -1333,7 +1575,8 @@ def main():
resume_from=args.resume_from, resume_from=args.resume_from,
temperature=args.temperature, temperature=args.temperature,
trailing_fade_ms=args.trailing_fade_ms, trailing_fade_ms=args.trailing_fade_ms,
max_new_tokens=args.max_tokens) max_new_tokens=args.max_tokens,
retries=args.truncation_retries)
assembled_tts = clips_dir / "tts_assembled.flac" assembled_tts = clips_dir / "tts_assembled.flac"
assemble_timed_audio(clips, str(assembled_tts)) assemble_timed_audio(clips, str(assembled_tts))
+15
View File
@@ -29,6 +29,11 @@ temperature = 0.6
# mid-word. # mid-word.
max_tokens = 4096 max_tokens = 4096
# Roughly one clip in twelve comes back cut off mid-word. Those are detected and
# asked for again with a different seed, up to this many extra attempts. 0 to
# switch retrying off.
truncation_retries = 2
# Trim this many ms from the start of every clip, to remove an inhale artifact. # Trim this many ms from the start of every clip, to remove an inhale artifact.
# Try 80-150 if you hear one. Too high clips the first consonant. # Try 80-150 if you hear one. Too high clips the first consonant.
strip_leading_ms = 0 strip_leading_ms = 0
@@ -54,3 +59,13 @@ output_dir = output
[comfy] [comfy]
# Address of the ComfyUI backend running Higgs Audio V3. # Address of the ComfyUI backend running Higgs Audio V3.
comfy_url = http://127.0.0.1:8189 comfy_url = http://127.0.0.1:8189
# Start ComfyUI automatically when it stops answering. A crash loses its queue,
# so the clip in flight is resubmitted once the backend is back. At most 3
# attempts per run, 90s apart, so a backend that dies on startup cannot spawn an
# endless stream of terminal windows. yes/no.
autostart = yes
# The script used to start it. Relative paths are resolved next to
# lektor_ai_comfy.py.
start_command = start_comfyui.sh
+8 -1
View File
@@ -222,12 +222,15 @@ SETTING_LABELS = {
"speed": "speed", "speed": "speed",
"temperature": "speech temperature", "temperature": "speech temperature",
"max_tokens": "max tokens per clip", "max_tokens": "max tokens per clip",
"truncation_retries": "retries if cut mid-word",
"strip_leading_ms": "strip leading (ms)", "strip_leading_ms": "strip leading (ms)",
"trailing_fade_ms": "trailing fade (ms)", "trailing_fade_ms": "trailing fade (ms)",
"orig_volume": "sound volume", "orig_volume": "sound volume",
"tts_volume": "voiceover volume", "tts_volume": "voiceover volume",
"output_dir": "output folder", "output_dir": "output folder",
"comfy_url": "ComfyUI URL", "comfy_url": "ComfyUI URL",
"comfy_autostart": "start ComfyUI if down",
"comfy_start_command": "start script",
} }
SECTION_TITLES = {"tts": "Speech", "mix": "Mix", "paths": "Paths", "comfy": "Backend"} SECTION_TITLES = {"tts": "Speech", "mix": "Mix", "paths": "Paths", "comfy": "Backend"}
@@ -353,7 +356,11 @@ class SettingsDialog(tk.Toplevel):
font=(F, 10)).grid(row=row, column=0, sticky="w", padx=(6, 14)) font=(F, 10)).grid(row=row, column=0, sticky="w", padx=(6, 14))
var = tk.StringVar(value=current[key]) var = tk.StringVar(value=current[key])
self.vars[key] = var self.vars[key] = var
if key == "voice" and voices: if core._DEFAULTS[key][2] is core._as_bool:
w = ttk.Combobox(body, textvariable=var, values=["yes", "no"],
width=26, state="readonly",
style="Dark.TCombobox", font=(F, 10))
elif key == "voice" and voices:
w = ttk.Combobox(body, textvariable=var, values=voices, width=26, w = ttk.Combobox(body, textvariable=var, values=voices, width=26,
style="Dark.TCombobox", font=(F, 10)) style="Dark.TCombobox", font=(F, 10))
else: else: