EARHORNMUSIC UPnP · Android TV · Phones · Android Auto · GNU/Linux
Release 0.5.0

LrcParser.kt

Each line, with the release that last changed it.

0.5.01package dev.stave.lyrics
2
3/**
4 * Parser for the LRC subtitle format as used by LRCLIB.
5 *
6 * Format:
7 * ```
8 * [00:12.34]First line
9 * [00:15.67]Second line
10 * [00:45.67][00:58.12]Chorus repeats at two timestamps
11 * [ar:Ryan Adams] (metadata tags — we ignore these)
12 * ```
13 *
14 * Returns lines sorted ascending by timestamp. Malformed lines (missing brackets,
15 * unparseable timestamps) are dropped silently — LRCLIB is usually clean but user-
16 * supplied LRC files in the wild can be weird, so best-effort parsing is the safest
17 * default.
18 */
19object LrcParser {
20
21 // `[mm:ss.xx]` or `[mm:ss.xxx]` — fractional precision varies across sources.
22 // The mm part can be 2+ digits (long tracks > 99 minutes are rare but allowed).
23 private val TIMESTAMP = Regex("""\[(\d{1,3}):(\d{2})(?:\.(\d{1,3}))?\]""")
24
25 // `[ar:…]`, `[ti:…]`, `[length:…]` — metadata tags we discard.
26 private val META_TAG = Regex("""\[[a-zA-Z]+:[^\]]*\]""")
27
28 fun parse(lrc: String): List<LrcLine> {
29 val out = mutableListOf<LrcLine>()
30 for (raw in lrc.lineSequence()) {
31 val line = raw.trim()
32 if (line.isBlank() || META_TAG.matchEntire(line) != null) continue
33
34 val stamps = TIMESTAMP.findAll(line).toList()
35 if (stamps.isEmpty()) continue
36
37 // Text is whatever comes after the last timestamp on the line. LRC
38 // allows multiple timestamps per line (repeat-chorus pattern) — each
39 // expands into its own LrcLine with the same text.
40 val lastStamp = stamps.last()
41 val text = line.substring(lastStamp.range.last + 1).trim()
42 // Skip lines with empty text — some LRC sources pad with empty
43 // [mm:ss.xx] markers to align timing with instrumental sections.
44 if (text.isBlank()) continue
45
46 for (m in stamps) {
47 val mm = m.groupValues[1].toIntOrNull() ?: continue
48 val ss = m.groupValues[2].toIntOrNull() ?: continue
49 val fracStr = m.groupValues[3]
50 // Normalize fractional to 3 digits. "34" → 340, "345" → 345, "" → 0.
51 val frac = if (fracStr.isBlank()) 0 else {
52 val padded = fracStr.padEnd(3, '0').take(3)
53 padded.toIntOrNull() ?: 0
54 }
55 val millis = (mm * 60_000L) + (ss * 1000L) + frac.toLong()
56 if (millis >= 0) out.add(LrcLine.at(millis, text))
57 }
58 }
59 return out.sortedBy { it.start }
60 }
61}