LrcParser.kt
61 lines · 2.6 KB
Changed in: 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 | } |


