package dev.stave.lyrics /** * Parser for the LRC subtitle format as used by LRCLIB. * * Format: * ``` * [00:12.34]First line * [00:15.67]Second line * [00:45.67][00:58.12]Chorus repeats at two timestamps * [ar:Ryan Adams] (metadata tags — we ignore these) * ``` * * Returns lines sorted ascending by timestamp. Malformed lines (missing brackets, * unparseable timestamps) are dropped silently — LRCLIB is usually clean but user- * supplied LRC files in the wild can be weird, so best-effort parsing is the safest * default. */ object LrcParser { // `[mm:ss.xx]` or `[mm:ss.xxx]` — fractional precision varies across sources. // The mm part can be 2+ digits (long tracks > 99 minutes are rare but allowed). private val TIMESTAMP = Regex("""\[(\d{1,3}):(\d{2})(?:\.(\d{1,3}))?\]""") // `[ar:…]`, `[ti:…]`, `[length:…]` — metadata tags we discard. private val META_TAG = Regex("""\[[a-zA-Z]+:[^\]]*\]""") fun parse(lrc: String): List { val out = mutableListOf() for (raw in lrc.lineSequence()) { val line = raw.trim() if (line.isBlank() || META_TAG.matchEntire(line) != null) continue val stamps = TIMESTAMP.findAll(line).toList() if (stamps.isEmpty()) continue // Text is whatever comes after the last timestamp on the line. LRC // allows multiple timestamps per line (repeat-chorus pattern) — each // expands into its own LrcLine with the same text. val lastStamp = stamps.last() val text = line.substring(lastStamp.range.last + 1).trim() // Skip lines with empty text — some LRC sources pad with empty // [mm:ss.xx] markers to align timing with instrumental sections. if (text.isBlank()) continue for (m in stamps) { val mm = m.groupValues[1].toIntOrNull() ?: continue val ss = m.groupValues[2].toIntOrNull() ?: continue val fracStr = m.groupValues[3] // Normalize fractional to 3 digits. "34" → 340, "345" → 345, "" → 0. val frac = if (fracStr.isBlank()) 0 else { val padded = fracStr.padEnd(3, '0').take(3) padded.toIntOrNull() ?: 0 } val millis = (mm * 60_000L) + (ss * 1000L) + frac.toLong() if (millis >= 0) out.add(LrcLine.at(millis, text)) } } return out.sortedBy { it.start } } }