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

LrcParser.kt

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<LrcLine> {
        val out = mutableListOf<LrcLine>()
        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 }
    }
}