"""Convert LRC lyrics to SRT subtitles. LRC is the timestamped-lyrics format most lyric sites and players emit. This converter handles the shapes that actually show up in the wild: * ``[mm:ss.xx]`` and ``[mm:ss.xxx]`` timestamps, and bare ``[mm:ss]`` * several timestamps on one line (``[00:05.00][00:30.00]chorus``), which is how repeated sections are written * ID tags (``[ti:Title]``, ``[ar:Artist]``, ``[offset:+250]``), which are metadata, not lyrics * multiple timestamped lines sharing one timestamp, which SRT wants as one cue with two subtitle lines Cue end times are inferred from the next cue's start, so the timing is driven by the LRC rather than invented. The final cue gets ``--last-duration`` seconds. Usage:: python3 scripts/lrc2srt.py song.lrc # -> song.srt python3 scripts/lrc2srt.py song.lrc -o subs/song.srt python3 scripts/lrc2srt.py lyrics/*.lrc --out-dir build/ python3 scripts/lrc2srt.py song.lrc --stdout # write to stdout """ from __future__ import annotations import argparse import os import re import sys from dataclasses import dataclass, field if __package__ in (None, ""): sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from _common import ( # type: ignore[import-not-found] Die, Report, add_common_arguments, main_wrapper, ) else: # pragma: no cover from ._common import ( Die, Report, add_common_arguments, main_wrapper, ) # --------------------------------------------------------------------------- # # Parsing # --------------------------------------------------------------------------- # #: ``[mm:ss.xx]`` / ``[mm:ss.xxx]`` / ``[mm:ss]``, anywhere in a line. TIMESTAMP_RE = re.compile(r"\[(?P\d{1,3}):(?P\d{1,2})(?:[.:](?P\d{1,3}))?\]") #: ``[ti:...]`` / ``[ar:...]`` / ``[offset:...]`` — an identifier, not a time. TAG_RE = re.compile(r"^\[(?P[A-Za-z#]+):(?P.*)\]\s*$") #: Keys whose value is a signed millisecond count, not a timestamp. META_KEYS = { "ti", "al", "ar", "au", "by", "re", "ve", "length", "offset", "encoding", "tool", "id", } @dataclass class LyricLine: timestamp: float text: str @dataclass class LrcDocument: lines: list[LyricLine] = field(default_factory=list) metadata: dict[str, str] = field(default_factory=dict) offset_ms: int = 0 @property def title(self) -> str | None: return self.metadata.get("ti") @property def artist(self) -> str | None: return self.metadata.get("ar") def parse_timestamp(minutes: str, seconds: str, fraction: str | None) -> float: """Turn LRC timestamp parts into seconds. The fractional part is centiseconds in the common case (``.30`` = 300 ms) but milliseconds when three digits are given (``.300`` = 300 ms). The length of the field disambiguates. """ total = int(minutes) * 60 + int(seconds) if fraction: if len(fraction) == 3: total += int(fraction) / 1000 else: total += int(fraction) / 100 return total def parse_lrc(content: str) -> LrcDocument: """Parse LRC text into a document, sorted by timestamp.""" if content.startswith("\ufeff"): content = content[1:] content = content.replace("\r\n", "\n").replace("\r", "\n") document = LrcDocument() for raw in content.split("\n"): line = raw.strip() if not line: continue # Metadata tags first: [ti:...] looks a lot like [00:12] to a loose regex. tag = TAG_RE.match(line) if tag and tag.group("key").lower() in META_KEYS: key = tag.group("key").lower() value = tag.group("value").strip() if key == "offset": try: document.offset_ms = int(value) except ValueError: pass else: document.metadata[key] = value continue stamps = list(TIMESTAMP_RE.finditer(line)) if not stamps: # An untimed line. Attach it to the previous cue as a continuation # line rather than discarding the lyric entirely. if document.lines: previous = document.lines[-1] previous.text = f"{previous.text}\n{line}" continue text = TIMESTAMP_RE.sub("", line).strip() for stamp in stamps: document.lines.append( LyricLine( timestamp=parse_timestamp( stamp.group("m"), stamp.group("s"), stamp.group("frac") ), text=text, ) ) if document.offset_ms: shift = document.offset_ms / 1000 for line in document.lines: line.timestamp = max(0.0, line.timestamp + shift) document.lines.sort(key=lambda l: l.timestamp) return document # --------------------------------------------------------------------------- # # SRT output # --------------------------------------------------------------------------- # @dataclass class Cue: index: int start: float end: float lines: list[str] def format_srt_time(seconds: float) -> str: """Seconds to SRT ``HH:MM:SS,mmm``.""" if seconds < 0: seconds = 0.0 total_ms = int(round(seconds * 1000)) hours, remainder = divmod(total_ms, 3_600_000) minutes, remainder = divmod(remainder, 60_000) secs, millis = divmod(remainder, 1000) return f"{hours:02d}:{minutes:02d}:{secs:02d},{millis:03d}" def build_cues( lines: list[LyricLine], *, last_duration: float = 5.0, min_duration: float = 0.5, ) -> list[Cue]: """Group simultaneous lines into cues and infer each cue's end time. Lines sharing one timestamp become a single cue with several subtitle lines — the format singers actually expect for a two-line phrase. A cue ends when the *next distinct* timestamp arrives. """ if not lines: return [] # Collapse lines with identical timestamps into one cue. groups: list[tuple[float, list[str]]] = [] for line in lines: if groups and abs(groups[-1][0] - line.timestamp) < 1e-6: groups[-1][1].append(line.text) else: groups.append((line.timestamp, [line.text])) cues: list[Cue] = [] for i, (start, text_lines) in enumerate(groups): if i + 1 < len(groups): end = groups[i + 1][0] if end - start < min_duration: end = start + min_duration else: end = start + last_duration cues.append(Cue(index=i + 1, start=start, end=end, lines=text_lines)) return cues def render_srt(cues: list[Cue]) -> str: blocks = [] for cue in cues: body = "\n".join(cue.lines) blocks.append( f"{cue.index}\n{format_srt_time(cue.start)} --> {format_srt_time(cue.end)}\n{body}" ) return "\n\n".join(blocks) + "\n" # --------------------------------------------------------------------------- # # CLI # --------------------------------------------------------------------------- # def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="lrc2srt", description="Convert LRC lyrics to SRT subtitles.", epilog="Multiple timestamps on one line are expanded; ID tags are ignored.", ) add_common_arguments(parser) parser.add_argument("inputs", nargs="+", metavar="INPUT", help="LRC file(s) to convert") parser.add_argument("-o", "--output", metavar="FILE", help="output path (single input only)") parser.add_argument("--out-dir", metavar="DIR", help="write every output into this directory") parser.add_argument("--stdout", action="store_true", help="write the SRT to stdout, no file") parser.add_argument( "--last-duration", type=float, default=5.0, metavar="SECONDS", help="how long to hold the final line (default: 5)", ) parser.add_argument( "--min-duration", type=float, default=0.5, metavar="SECONDS", help="shortest cue allowed (default: 0.5)", ) parser.add_argument( "--title", help="override the SRT's implicit title (used for reporting only)", ) return parser def convert(src: str, args: argparse.Namespace, report: Report) -> tuple[int, str | None]: try: with open(src, "r", encoding="utf-8-sig", errors="replace") as handle: content = handle.read() except OSError as exc: raise Die(f"cannot read {src}: {exc.strerror}") from exc document = parse_lrc(content) if not document.lines: raise Die(f"{src}: no timestamped lyrics found — is this an LRC file?") cues = build_cues( document.lines, last_duration=args.last_duration, min_duration=args.min_duration, ) output = render_srt(cues) if args.stdout: sys.stdout.write(output) return len(cues), None if args.output: dest = args.output elif args.out_dir: dest = os.path.join(args.out_dir, os.path.splitext(os.path.basename(src))[0] + ".srt") else: dest = os.path.splitext(src)[0] + ".srt" if args.dry_run: report.info(f"[dry-run] would write {dest} ({len(cues)} cues)") return len(cues), dest os.makedirs(os.path.dirname(os.path.abspath(dest)), exist_ok=True) with open(dest, "w", encoding="utf-8", newline="\n") as handle: handle.write(output) label = document.title or os.path.basename(src) report.ok(f"{label} -> {dest} ({len(cues)} cues)") if document.artist: report.verbose(f"artist: {document.artist}") if document.offset_ms: report.verbose(f"applied LRC offset of {document.offset_ms:+d} ms") return len(cues), dest @main_wrapper def main() -> int: args = build_parser().parse_args() report = Report(quiet=args.quiet) if args.output and len(args.inputs) > 1: raise Die("--output takes a single input; use --out-dir for batches") if args.stdout and len(args.inputs) > 1: raise Die("--stdout takes a single input") total = 0 converted = 0 for src in args.inputs: if not os.path.isfile(src): raise Die(f"no such file: {src}") cues, _dest = convert(src, args, report) total += cues converted += 1 if converted > 1: report.info(f"{total} cues across {converted} file(s)") return 0 if __name__ == "__main__": raise SystemExit(main())