Add two standalone tools: prose to speech, TMPlayer to SRT
tts_txt.py reads a plain text file aloud with the lektor voice. The pipeline is built around subtitles — every clip is placed at a timestamp — so prose needs the one piece that is genuinely missing, a chunker that splits text into sentence-sized pieces (240 chars, on sentence boundaries, long sentences split at clause breaks). Everything else is lektor_ai_comfy's: the dictionary, the Polish number normalizer, the ComfyUI call with its truncation retries and crash recovery, the per-clip trim and speed-up. Clips land in output/clips/<stem>/ like a film's, so --resume-from and re-assembly work the same way, and --dry-run prints the chunks without generating anything. tmp2srt.py converts TMPlayer subtitles (HH:MM:SS:text, '|' for a line break) to SRT. TMPlayer stores only a start time, so an end is inferred: the next cue's start less a small gap, or sooner when 16 chars/second says the line can be read in less. The separator after the timestamp is optional in the wild — some files use ':', some a space, some nothing — so all three parse. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NkWDx9dVB7xBpqntvjvm6A
This commit is contained in:
+78
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Convert TMPlayer subtitles (HH:MM:SS:text, '|' = line break) to SRT.
|
||||
|
||||
TMPlayer stores only a start time per line, so end times are inferred:
|
||||
each cue ends when the next one starts (minus a small gap), or earlier if
|
||||
its text is short enough to be read sooner.
|
||||
|
||||
python3 tmp2srt.py input/film.txt [output.srt]
|
||||
"""
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# A separator after the timestamp is optional in the wild: some lines use
|
||||
# ':', some a bare space, some nothing at all.
|
||||
LINE_RE = re.compile(r"^\s*(\d{1,2}):(\d{2}):(\d{2}):?\s*(.*)$")
|
||||
|
||||
CPS = 16.0 # reading speed used to size a cue when there is room
|
||||
MIN_DUR = 1.2 # s
|
||||
MAX_DUR = 7.0 # s
|
||||
GAP = 0.08 # s left between consecutive cues
|
||||
|
||||
|
||||
def _fmt(t: float) -> str:
|
||||
ms = int(round(t * 1000))
|
||||
h, ms = divmod(ms, 3600000)
|
||||
m, ms = divmod(ms, 60000)
|
||||
s, ms = divmod(ms, 1000)
|
||||
return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
|
||||
|
||||
|
||||
def parse(path: Path):
|
||||
cues, skipped = [], []
|
||||
for n, raw in enumerate(path.read_text(encoding="utf-8-sig").splitlines(), 1):
|
||||
if not raw.strip():
|
||||
continue
|
||||
m = LINE_RE.match(raw)
|
||||
if not m:
|
||||
skipped.append((n, raw))
|
||||
continue
|
||||
h, mi, s, text = m.groups()
|
||||
text = text.replace("|", "\n").strip()
|
||||
if not text:
|
||||
continue
|
||||
cues.append((int(h) * 3600 + int(mi) * 60 + int(s), text))
|
||||
cues.sort(key=lambda c: c[0])
|
||||
return cues, skipped
|
||||
|
||||
|
||||
def to_srt(cues) -> str:
|
||||
out = []
|
||||
for i, (start, text) in enumerate(cues):
|
||||
wanted = len(text.replace("\n", " ")) / CPS
|
||||
end = start + max(MIN_DUR, min(MAX_DUR, wanted))
|
||||
if i + 1 < len(cues):
|
||||
end = min(end, cues[i + 1][0] - GAP)
|
||||
if end <= start: # cues closer together than MIN_DUR
|
||||
end = (cues[i + 1][0] if i + 1 < len(cues) else start + MIN_DUR) - 0.01
|
||||
if end <= start:
|
||||
end = start + 0.1
|
||||
out.append(f"{i + 1}\n{_fmt(start)} --> {_fmt(end)}\n{text}\n")
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
sys.exit(__doc__)
|
||||
src = Path(sys.argv[1])
|
||||
dst = Path(sys.argv[2]) if len(sys.argv) > 2 else src.with_suffix(".srt")
|
||||
cues, skipped = parse(src)
|
||||
dst.write_text(to_srt(cues), encoding="utf-8")
|
||||
print(f"{src} -> {dst}: {len(cues)} cues")
|
||||
for n, raw in skipped:
|
||||
print(f" skipped line {n}: {raw[:70]!r}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+338
@@ -0,0 +1,338 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Read a plain text file aloud with the lektor_ai voice — no video, no SRT.
|
||||
|
||||
The dubbing pipeline is built around subtitles: every clip is placed at a
|
||||
timestamp against a film. Prose has no timings, so this script does the one
|
||||
thing that is genuinely missing — it splits the text into sentence-sized
|
||||
chunks itself — and then reuses lektor_ai_comfy for everything else: the same
|
||||
dictionary, the same Polish number normalizer, the same ComfyUI TTS call with
|
||||
its truncation retries and crash recovery, the same per-clip trim and speed-up.
|
||||
The clips are laid end to end with a short pause instead of at an SRT time.
|
||||
|
||||
python3 tts_txt.py input/oko_proroka.txt
|
||||
python3 tts_txt.py input/oko_proroka.txt -o output/oko.mp3 --speed 1.1
|
||||
python3 tts_txt.py input/oko_proroka.txt --dry-run # just show the chunks
|
||||
|
||||
Clips live in output/clips/<stem>/ exactly as for a film, so an interrupted
|
||||
run resumes with --resume-from N and a re-assembly costs nothing.
|
||||
"""
|
||||
import argparse
|
||||
import random
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import lektor_ai_comfy as lek
|
||||
|
||||
# Chunk size is a compromise, not a limit of the model: longer chunks read with
|
||||
# better prosody but are likelier to come back cut off mid-word, and a retry
|
||||
# then costs the whole chunk. Subtitle blocks — what the voice was tuned on —
|
||||
# sit well under this.
|
||||
MAX_CHARS = 240
|
||||
|
||||
SENTENCE_GAP = 0.35 # s of silence between chunks
|
||||
PARAGRAPH_GAP = 0.90 # s at a blank line
|
||||
|
||||
# A dot after one of these is not the end of a sentence. Single letters are
|
||||
# handled separately ("p. kasztelana", initials).
|
||||
ABBREV = {
|
||||
"np.", "itd.", "itp.", "tzn.", "tj.", "ok.", "dr.", "prof.", "św.",
|
||||
"ul.", "nr.", "str.", "m.in.", "tzw.", "ds.", "godz.", "mln.", "mld.",
|
||||
}
|
||||
|
||||
_SENT_SPLIT = re.compile(r'(?<=[.!?…])["»”\'’)\]]*\s+')
|
||||
_CLOSERS = '"»”\'’)]'
|
||||
_CLAUSE_SPLIT = re.compile(r'(?<=[,;:])\s+|\s+(?=—\s)')
|
||||
|
||||
|
||||
def _terminal_punct(text: str) -> str:
|
||||
"""Give the chunk an ending the model can land on.
|
||||
|
||||
lek._ensure_terminal_punct is written for subtitles and does not know the
|
||||
single-character ellipsis or a closing quote, so it would turn `uciekło…`
|
||||
into `uciekło……` and `jeść!”` into `jeść!”...`.
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
bare = text.rstrip(_CLOSERS)
|
||||
if bare and bare[-1] in '.,!?;:…':
|
||||
return text
|
||||
return text + '…'
|
||||
|
||||
|
||||
def _prepare(text: str, dictionary) -> str:
|
||||
text = lek._strip_sub_tags(text)
|
||||
# A chunk cut out of the middle of a long sentence starts lowercase, which
|
||||
# the TTS reads with the wrong intonation — the pipeline capitalises these
|
||||
# for the same reason.
|
||||
if text and text[0].islower():
|
||||
text = text[0].upper() + text[1:]
|
||||
if dictionary is not None:
|
||||
text = dictionary.apply(text)
|
||||
return _terminal_punct(text)
|
||||
|
||||
|
||||
def _is_real_boundary(before: str) -> bool:
|
||||
"""False when the dot that ended `before` belongs to an abbreviation."""
|
||||
tail = before.rstrip('"»”\'’)]').split()[-1] if before.split() else ""
|
||||
if not tail.endswith("."):
|
||||
return True # ended on ! ? … — always a boundary
|
||||
if tail.lower() in ABBREV:
|
||||
return False
|
||||
stem = tail[:-1]
|
||||
return not (len(stem) == 1 and stem.isalpha()) # "p.", initials
|
||||
|
||||
|
||||
def split_sentences(paragraph: str) -> list:
|
||||
parts, pos = [], 0
|
||||
for m in _SENT_SPLIT.finditer(paragraph):
|
||||
candidate = paragraph[pos:m.start()]
|
||||
if not _is_real_boundary(candidate):
|
||||
continue
|
||||
parts.append(paragraph[pos:m.end()].strip())
|
||||
pos = m.end()
|
||||
tail = paragraph[pos:].strip()
|
||||
if tail:
|
||||
parts.append(tail)
|
||||
return parts
|
||||
|
||||
|
||||
def _split_long(sentence: str, max_chars: int) -> list:
|
||||
"""A sentence longer than one chunk, broken at commas and dashes.
|
||||
|
||||
Nineteenth-century prose runs to sentences of several hundred characters;
|
||||
handing one to the model whole is exactly the case that comes back
|
||||
truncated.
|
||||
"""
|
||||
pieces, current = [], ""
|
||||
for part in _CLAUSE_SPLIT.split(sentence):
|
||||
if current and len(current) + 1 + len(part) > max_chars:
|
||||
pieces.append(current)
|
||||
current = part
|
||||
else:
|
||||
current = f"{current} {part}".strip()
|
||||
if current:
|
||||
pieces.append(current)
|
||||
return pieces
|
||||
|
||||
|
||||
def chunk_text(raw: str, max_chars: int = MAX_CHARS) -> list:
|
||||
"""Text -> [(chunk_text, ends_paragraph)].
|
||||
|
||||
Hard line wraps inside a paragraph are joined: they are an artefact of how
|
||||
the file was typeset, not pauses the reader should hear. A blank line is a
|
||||
real break and becomes the longer pause.
|
||||
"""
|
||||
chunks = []
|
||||
paragraphs = [p for p in re.split(r'\n\s*\n', raw) if p.strip()]
|
||||
for para in paragraphs:
|
||||
joined = re.sub(r'\s*\n\s*', ' ', para.strip())
|
||||
joined = re.sub(r'([?!.…])(—)', r'\1 \2', joined) # "jak?— Ja" -> "jak? — Ja"
|
||||
joined = re.sub(r'\s{2,}', ' ', joined)
|
||||
buf = []
|
||||
for sentence in split_sentences(joined):
|
||||
for piece in ([sentence] if len(sentence) <= max_chars
|
||||
else _split_long(sentence, max_chars)):
|
||||
if buf and len(" ".join(buf)) + 1 + len(piece) > max_chars:
|
||||
chunks.append([" ".join(buf), False])
|
||||
buf = []
|
||||
buf.append(piece)
|
||||
if buf:
|
||||
chunks.append([" ".join(buf), False])
|
||||
if chunks:
|
||||
chunks[-1][1] = True # last chunk of the paragraph
|
||||
return [(t, end) for t, end in chunks]
|
||||
|
||||
|
||||
def main():
|
||||
pre = argparse.ArgumentParser(add_help=False)
|
||||
pre.add_argument("--settings", default=str(lek.SETTINGS_FILE), metavar="FILE")
|
||||
settings_path = Path(pre.parse_known_args()[0].settings).expanduser()
|
||||
cfg = lek.load_settings(settings_path)
|
||||
|
||||
p = argparse.ArgumentParser(
|
||||
description="Turn a plain text file into a lektor-voiced audio file.",
|
||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
|
||||
p.add_argument("text", help="UTF-8 .txt file to read aloud")
|
||||
p.add_argument("-o", "--output", default=None, metavar="FILE",
|
||||
help="output audio (extension picks the format); "
|
||||
"default <output_dir>/<stem>.mp3")
|
||||
p.add_argument("--settings", default=str(lek.SETTINGS_FILE), metavar="FILE")
|
||||
p.add_argument("--voice", default=cfg["voice"],
|
||||
help="voice name in voices/ or a path to a WAV")
|
||||
# 1.0 rather than the settings default: the pipeline's speed-up exists to
|
||||
# fit a clip into its subtitle slot, and prose has no slot to fit.
|
||||
p.add_argument("--speed", type=float, default=1.0, metavar="FACTOR")
|
||||
p.add_argument("--temperature", type=float, default=cfg["temperature"], metavar="T")
|
||||
p.add_argument("--max-tokens", type=int, default=cfg["max_tokens"], metavar="N")
|
||||
p.add_argument("--seed", type=int, default=0, help="0 = random")
|
||||
p.add_argument("--truncation-retries", type=int,
|
||||
default=cfg["truncation_retries"], metavar="N")
|
||||
p.add_argument("--strip-leading-ms", type=float, default=cfg["strip_leading_ms"])
|
||||
p.add_argument("--trailing-fade-ms", type=float, default=cfg["trailing_fade_ms"])
|
||||
p.add_argument("--dictionary", default=cfg["dictionary"], metavar="FILE",
|
||||
help='replacement list; "" disables')
|
||||
p.add_argument("--dictionary-extra", default=None, metavar="FILE",
|
||||
help="second list merged on top, for rules specific to this text")
|
||||
numbers = p.add_mutually_exclusive_group()
|
||||
numbers.add_argument("--normalize-numbers", dest="normalize_numbers",
|
||||
action="store_true", default=cfg["normalize_numbers"])
|
||||
numbers.add_argument("--no-normalize-numbers", dest="normalize_numbers",
|
||||
action="store_false")
|
||||
p.add_argument("--max-chars", type=int, default=MAX_CHARS, metavar="N",
|
||||
help="longest chunk handed to the model in one go")
|
||||
p.add_argument("--sentence-gap", type=float, default=SENTENCE_GAP, metavar="S")
|
||||
p.add_argument("--paragraph-gap", type=float, default=PARAGRAPH_GAP, metavar="S")
|
||||
p.add_argument("--limit", type=int, default=0, metavar="N",
|
||||
help="generate only the first N chunks (for a quick listen)")
|
||||
p.add_argument("--resume-from", type=int, default=0, metavar="N",
|
||||
help="reuse clips 1..N-1 already in the job folder")
|
||||
p.add_argument("--rebuild", action="store_true",
|
||||
help="re-assemble from the clips on disk, no TTS")
|
||||
p.add_argument("--dry-run", action="store_true",
|
||||
help="print the chunks and exit without touching ComfyUI")
|
||||
p.add_argument("--output-dir", default=cfg["output_dir"])
|
||||
p.add_argument("--comfy-url", default=cfg["comfy_url"])
|
||||
p.add_argument("--no-comfy-autostart", action="store_true")
|
||||
args = p.parse_args()
|
||||
|
||||
lek.COMFY_URL = args.comfy_url
|
||||
lek.COMFY_AUTOSTART = cfg["comfy_autostart"] and not args.no_comfy_autostart
|
||||
start_cmd = Path(cfg["comfy_start_command"]).expanduser()
|
||||
lek.COMFY_START_SCRIPT = (start_cmd if start_cmd.is_absolute()
|
||||
else Path(lek.__file__).resolve().parent / start_cmd)
|
||||
|
||||
src = Path(args.text)
|
||||
if not src.is_file():
|
||||
sys.exit(f"No such text file: {src}")
|
||||
out_dir = Path(args.output_dir)
|
||||
clips_dir = lek.job_dir(out_dir, str(src))
|
||||
clips_dir.mkdir(parents=True, exist_ok=True)
|
||||
final = Path(args.output) if args.output else out_dir / f"{src.stem}.mp3"
|
||||
|
||||
print(f" Settings: {settings_path if settings_path.is_file() else 'built-in defaults'}")
|
||||
print(f" Job folder: {clips_dir}")
|
||||
|
||||
# ── Step 1: Text -> blocks ────────────────────────────────────────────────
|
||||
print(f"\n[1/3] Splitting {src.name}…")
|
||||
chunks = chunk_text(src.read_text(encoding="utf-8-sig"), args.max_chars)
|
||||
if not chunks:
|
||||
sys.exit(f"{src} has no text.")
|
||||
if args.limit:
|
||||
chunks = chunks[:args.limit]
|
||||
print(f" --limit {args.limit}: only the first {len(chunks)} chunk(s).")
|
||||
|
||||
wanted = []
|
||||
if args.dictionary.strip():
|
||||
wanted.append(lek.resolve_dictionary_path(args.dictionary))
|
||||
if args.dictionary_extra and args.dictionary_extra.strip():
|
||||
wanted.append(Path(args.dictionary_extra).expanduser())
|
||||
dictionary = lek.Dictionary.load_all(wanted) if wanted else None
|
||||
if dictionary is not None:
|
||||
for path, count in dictionary.sources:
|
||||
print(f" Dictionary: {count} rule(s) from {path}")
|
||||
|
||||
# The synthetic timings are placeholders — every clip asks to start at 0 and
|
||||
# generate_clips pushes it after the previous one. The real spacing is
|
||||
# applied after generation, when the durations are known.
|
||||
blocks, gaps = [], []
|
||||
for text, ends_para in chunks:
|
||||
clean = _prepare(text, dictionary)
|
||||
blocks.append((0.0, 0.0, clean))
|
||||
gaps.append(args.paragraph_gap if ends_para else args.sentence_gap)
|
||||
if dictionary is not None and dictionary.hits:
|
||||
print(f" {dictionary.hits} dictionary replacement(s) applied.")
|
||||
|
||||
|
||||
if args.normalize_numbers:
|
||||
py = Path(cfg["normalizer_python"]).expanduser()
|
||||
if not py.is_absolute():
|
||||
py = Path(lek.__file__).resolve().parent / py
|
||||
llm = Path(cfg["normalizer_llm"]).expanduser() if cfg["normalizer_llm"] else None
|
||||
if llm and not llm.is_absolute():
|
||||
llm = Path(lek.__file__).resolve().parent / llm
|
||||
blocks = lek.normalize_block_numbers(blocks, py, cfg["normalizer_timeout"],
|
||||
free_vram=cfg["free_comfy_vram"],
|
||||
llm_dir=llm)
|
||||
|
||||
chars = sum(len(t) for _s, _e, t in blocks)
|
||||
print(f" {len(blocks)} chunk(s), {chars} characters "
|
||||
f"(longest {max(len(t) for _s, _e, t in blocks)}).")
|
||||
|
||||
preview = clips_dir / "tts_input.txt"
|
||||
preview.write_text("\n\n".join(f"[{i + 1}] {t}" for i, (_s, _e, t) in enumerate(blocks)),
|
||||
encoding="utf-8")
|
||||
print(f" Saved to {preview} for inspection.")
|
||||
|
||||
if args.dry_run:
|
||||
for i, (_s, _e, text) in enumerate(blocks, 1):
|
||||
print(f" [{i:4d}] ({len(text):3d}) {text}")
|
||||
return
|
||||
|
||||
# ── Step 2: TTS ───────────────────────────────────────────────────────────
|
||||
if args.rebuild:
|
||||
clips = [(0.0, path) for path in
|
||||
sorted(clips_dir.glob("clip_[0-9][0-9][0-9][0-9].flac"))]
|
||||
if not clips:
|
||||
sys.exit(f"--rebuild: no clips in {clips_dir}.")
|
||||
print(f"\n[2/3] Rebuilding from {len(clips)} existing clip(s) — no TTS.")
|
||||
else:
|
||||
print(f"\n[2/3] Generating TTS ({len(blocks)} clips, speed {args.speed}×)…")
|
||||
wav_path = Path(args.voice)
|
||||
if not wav_path.is_file():
|
||||
wav_path = lek.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}")
|
||||
|
||||
# A cold backend answers the first submission with a connection error,
|
||||
# which run_workflow would count against the clip's crash budget.
|
||||
if not lek.wait_for_backend(2.0):
|
||||
print(" ComfyUI is not up — starting it…")
|
||||
if not (lek.COMFY_AUTOSTART and lek.start_comfy_backend(lek.COMFY_START_SCRIPT)
|
||||
and lek.wait_for_backend(lek.BACKEND_HEALTH_TIMEOUT)):
|
||||
sys.exit(f"ComfyUI did not come up at {lek.COMFY_URL}.")
|
||||
|
||||
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 = lek.upload_voice_wav(wav_path)
|
||||
|
||||
clips = lek.generate_clips(blocks, 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,
|
||||
retries=args.truncation_retries)
|
||||
if cfg["free_comfy_vram"]:
|
||||
lek.free_comfy_vram()
|
||||
|
||||
# ── Step 3: Lay the clips end to end ─────────────────────────────────────
|
||||
print(f"\n[3/3] Assembling…")
|
||||
timed, cursor = [], 0.0
|
||||
for _start, path in clips:
|
||||
# generate_clips skips a clip that repeatedly killed the backend, so the
|
||||
# gap belongs to the block the clip came from, not to its position here.
|
||||
idx = int(re.search(r'(\d+)', path.stem).group(1)) - 1
|
||||
timed.append((cursor, path))
|
||||
cursor += lek.get_audio_duration(path)
|
||||
cursor += gaps[idx] if idx < len(gaps) else args.sentence_gap
|
||||
|
||||
assembled = clips_dir / "tts_assembled.flac"
|
||||
lek.assemble_timed_audio(timed, str(assembled))
|
||||
|
||||
final.parent.mkdir(parents=True, exist_ok=True)
|
||||
if final.suffix.lower() == ".flac":
|
||||
assembled.replace(final)
|
||||
else:
|
||||
lek._run(["ffmpeg", "-y", "-i", str(assembled), "-b:a", "192k", str(final)],
|
||||
f"Encoding {final.name}")
|
||||
|
||||
total = lek.get_audio_duration(final)
|
||||
print(f"\nDone! Output: {final} ({int(total // 60)}:{int(total % 60):02d}, "
|
||||
f"{final.stat().st_size // 1024} KB)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user