Files

354 lines
11 KiB
Python
Raw Permalink Normal View History

"""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
2026-09-10 07:41:07 +00:00
import re
import sys
from dataclasses import dataclass, field
2026-09-10 07:41:07 +00:00
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<m>\d{1,3}):(?P<s>\d{1,2})(?:[.:](?P<frac>\d{1,3}))?\]")
#: ``[ti:...]`` / ``[ar:...]`` / ``[offset:...]`` — an identifier, not a time.
TAG_RE = re.compile(r"^\[(?P<key>[A-Za-z#]+):(?P<value>.*)\]\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")
2026-09-10 07:41:07 +00:00
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())