/
/
/
1"""Helpers for processing lyrics."""
2
3from __future__ import annotations
4
5import re
6
7# LRC ID tags such as [ar:Artist] or [ti:Title] have a key that never starts with a digit,
8# which distinguishes them from the [mm:ss.xx] timestamp tags on actual lyric lines.
9# Also matches colon-less comment lines like [#some comment].
10_LRC_ID_TAG_RE = re.compile(r"^\[(?:[a-zA-Z#][a-zA-Z0-9_]*:[^\]]*|#[^\]]*)\]\s*$")
11
12# a single [mm:ss.xx] timestamp tag (the fraction separator may be . or :)
13_LRC_TIMESTAMP_RE = re.compile(r"\[(\d{1,3}):(\d{1,2}(?:[.:]\d{1,3})?)\]")
14
15# one or more (optionally whitespace separated) timestamp tags at the start of a lyric line
16_LRC_TIMESTAMP_BLOCK_RE = re.compile(r"^((?:\[\d{1,3}:\d{1,2}(?:[.:]\d{1,3})?\]\s*)+)(.*)$")
17
18# enhanced LRC word timing tags such as <00:22.00>, which would render as literal text
19# TODO: consider supporting word level timing in the future instead of stripping it
20_LRC_WORD_TIMING_RE = re.compile(r"<\d{1,3}:\d{1,2}(?:[.:]\d{1,3})?>\s?")
21
22
23def normalize_lrc_lyrics(lrc_lyrics: str | None) -> str | None:
24 """
25 Normalize LRC formatted lyrics into simple, chronologically sorted LRC.
26
27 Strips ID/metadata tag lines (e.g. [ar:...], [ti:...]) and word timing tags,
28 and expands lines with multiple timestamps (repeating lyrics such as a chorus)
29 into one line per timestamp, so clients only need a minimal LRC parser.
30
31 :param lrc_lyrics: The LRC formatted lyrics to normalize, may be None.
32 """
33 if not lrc_lyrics:
34 return lrc_lyrics
35 entries: list[tuple[float, str]] = []
36 # untimed lines inherit the previous timestamp so the (stable) sort keeps them in place
37 last_time = 0.0
38 for line in lrc_lyrics.splitlines():
39 if _LRC_ID_TAG_RE.match(line.strip()):
40 continue
41 for time, lyric_line in _expand_line(_LRC_WORD_TIMING_RE.sub("", line)):
42 last_time = time if time is not None else last_time
43 entries.append((last_time, lyric_line))
44 entries.sort(key=lambda entry: entry[0])
45 return "\n".join(entry[1] for entry in entries).strip("\n") or None
46
47
48def extract_lrc_lyrics(lyrics: str | None) -> str | None:
49 """
50 Return the given plain lyrics text if it is LRC formatted, None otherwise.
51
52 :param lyrics: The plain lyrics text to inspect, may be None.
53 """
54 if not lyrics:
55 return None
56 stripped_lines = (line.strip() for line in lyrics.splitlines())
57 content_lines = [line for line in stripped_lines if line and not _LRC_ID_TAG_RE.match(line)]
58 if not content_lines:
59 return None
60 timestamped = sum(1 for line in content_lines if _LRC_TIMESTAMP_BLOCK_RE.match(line))
61 # require most lines to carry a leading timestamp to avoid false positives on
62 # plain lyrics that merely mention something like [2:30] somewhere in the text
63 if timestamped * 2 < len(content_lines):
64 return None
65 return lyrics
66
67
68def _expand_line(line: str) -> list[tuple[float | None, str]]:
69 """Expand a lyric line into (time, line) entries, one per leading timestamp."""
70 block_match = _LRC_TIMESTAMP_BLOCK_RE.match(line)
71 if not block_match:
72 return [(None, line)]
73 timestamps = list(_LRC_TIMESTAMP_RE.finditer(block_match.group(1)))
74 if len(timestamps) == 1:
75 return [(_timestamp_to_seconds(timestamps[0]), line)]
76 text = block_match.group(2)
77 return [
78 (_timestamp_to_seconds(ts), f"{ts.group(0)}{text}")
79 for ts in sorted(timestamps, key=_timestamp_to_seconds)
80 ]
81
82
83def _timestamp_to_seconds(timestamp: re.Match[str]) -> float:
84 """Return the time in seconds represented by a matched timestamp tag."""
85 minutes = int(timestamp.group(1))
86 seconds = float(timestamp.group(2).replace(":", "."))
87 return minutes * 60 + seconds
88
89
90def convert_to_lrc_lyrics(lyrics: list[tuple[str, int]]) -> str:
91 """
92 Convert lyrics to LRC format.
93
94 :param lyrics: A list of (text, timestamp in ms) pairs.
95 """
96
97 def format_line(text: str, ms: int) -> str | None:
98 if ms < 0:
99 return None
100
101 mins, ms = divmod(ms, 60_000)
102 secs, ms = divmod(ms, 1_000)
103
104 if mins > 99:
105 return None
106
107 # Replace newline and carriage return and strip leading/trailing whitespace
108 text = text.replace("\r\n", "\n").replace("\r", "\n").replace("\n", " ").strip()
109
110 return f"[{mins:02d}:{secs:02d}.{ms // 10:02d}]{text}"
111
112 return "\n".join(line for text, ms in lyrics if (line := format_line(text, ms)) is not None)
113