LrcParser.kt
Each line, with the release that last changed it.
| 0.5.0 | 1 | package 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 | */ | |
| 19 | object 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 | } |


