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()
|
||||
Reference in New Issue
Block a user