diff --git a/tmp2srt.py b/tmp2srt.py new file mode 100644 index 0000000..6e8d608 --- /dev/null +++ b/tmp2srt.py @@ -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() diff --git a/tts_txt.py b/tts_txt.py new file mode 100755 index 0000000..69cc20a --- /dev/null +++ b/tts_txt.py @@ -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// 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 /.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()