LrcParser.kt
52 lines · 2.2 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 | * Handles standard `[mm:ss.xx]text` lines, multi-timestamp lines |
| 7 | * (`[00:12.34][00:45.67]chorus`) which expand into multiple LrcLines with the same |
| 8 | * text, metadata tags (`[ar:…]`, `[ti:…]`) which are discarded, and malformed lines |
| 9 | * which are silently skipped. |
| 10 | * |
| 11 | * Returns lines sorted ascending by timestamp. |
| 12 | */ |
| 13 | object LrcParser { |
| 14 | |
| 15 | // `[mm:ss.xx]` or `[mm:ss.xxx]`. mm can be 2+ digits for tracks over 99 minutes. |
| 16 | private val TIMESTAMP = Regex("""\[(\d{1,3}):(\d{2})(?:\.(\d{1,3}))?\]""") |
| 17 | |
| 18 | // `[ar:…]`, `[ti:…]`, `[length:…]` — metadata tags we discard. |
| 19 | private val META_TAG = Regex("""\[[a-zA-Z]+:[^\]]*\]""") |
| 20 | |
| 21 | fun parse(lrc: String): List<LrcLine> { |
| 22 | val out = mutableListOf<LrcLine>() |
| 23 | for (raw in lrc.lineSequence()) { |
| 24 | val line = raw.trim() |
| 25 | if (line.isBlank() || META_TAG.matchEntire(line) != null) continue |
| 26 | |
| 27 | val stamps = TIMESTAMP.findAll(line).toList() |
| 28 | if (stamps.isEmpty()) continue |
| 29 | |
| 30 | // Text is whatever comes after the last timestamp on the line. |
| 31 | val lastStamp = stamps.last() |
| 32 | val text = line.substring(lastStamp.range.last + 1).trim() |
| 33 | // Skip lines with empty text — some sources pad with empty timestamp |
| 34 | // markers to align timing with instrumental sections. |
| 35 | if (text.isBlank()) continue |
| 36 | |
| 37 | for (m in stamps) { |
| 38 | val mm = m.groupValues[1].toIntOrNull() ?: continue |
| 39 | val ss = m.groupValues[2].toIntOrNull() ?: continue |
| 40 | val fracStr = m.groupValues[3] |
| 41 | // Normalize fractional to 3 digits. "34" → 340, "345" → 345, "" → 0. |
| 42 | val frac = if (fracStr.isBlank()) 0 else { |
| 43 | val padded = fracStr.padEnd(3, '0').take(3) |
| 44 | padded.toIntOrNull() ?: 0 |
| 45 | } |
| 46 | val millis = (mm * 60_000L) + (ss * 1000L) + frac.toLong() |
| 47 | if (millis >= 0) out.add(LrcLine.at(millis, text)) |
| 48 | } |
| 49 | } |
| 50 | return out.sortedBy { it.start } |
| 51 | } |
| 52 | } |


