Add LRC/Enhanced LRC parser (line and word-level timing, UTF-8 safe)
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
// Package lyrics parses LRC and Enhanced LRC lyric files into a
|
||||
// structured, timing-preserving representation, without inventing any
|
||||
// data the source file doesn't actually contain: a plain LRC file
|
||||
// produces line-level timing only; word-level cues are only populated
|
||||
// when the file actually has inline word timestamps.
|
||||
package lyrics
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Word is one word/syllable-level timed cue within a Line, present only
|
||||
// for Enhanced LRC input.
|
||||
type Word struct {
|
||||
StartMs int
|
||||
Text string
|
||||
}
|
||||
|
||||
// Line is one line of lyrics, synced to a start time.
|
||||
type Line struct {
|
||||
StartMs int
|
||||
Text string
|
||||
Words []Word // empty unless the source had inline word timing
|
||||
}
|
||||
|
||||
// Parsed is the result of parsing one lyric file.
|
||||
type Parsed struct {
|
||||
Lang string // from an [la:xx] tag, "" if absent
|
||||
OffsetMs int // from an [offset:+/-n] tag, 0 if absent
|
||||
Lines []Line
|
||||
Enhanced bool // true if any line has word-level Words
|
||||
}
|
||||
|
||||
var (
|
||||
lineTimeRE = regexp.MustCompile(`^\[(\d{1,3}):(\d{2})(?:[.:](\d{1,3}))?\]`)
|
||||
metaTagRE = regexp.MustCompile(`^\[([a-zA-Z]+):(.*)\]$`)
|
||||
wordTimeRE = regexp.MustCompile(`<(\d{1,3}):(\d{2})(?:[.:](\d{1,3}))?>`)
|
||||
)
|
||||
|
||||
// Parse parses LRC (and, transparently, Enhanced LRC) content. It never
|
||||
// returns an error for merely-unrecognized lines — those are skipped,
|
||||
// matching how real-world LRC files in the wild often carry stray
|
||||
// metadata this parser doesn't know about; only genuinely empty input
|
||||
// produces a Parsed with no lines.
|
||||
func Parse(content string) *Parsed {
|
||||
p := &Parsed{}
|
||||
lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n")
|
||||
|
||||
for _, raw := range lines {
|
||||
line := strings.TrimSpace(raw)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if m := metaTagRE.FindStringSubmatch(line); m != nil && !lineTimeRE.MatchString(line) {
|
||||
applyMetaTag(p, strings.ToLower(m[1]), strings.TrimSpace(m[2]))
|
||||
continue
|
||||
}
|
||||
|
||||
// A line may carry multiple leading [mm:ss.xx] timestamps
|
||||
// (repeated chorus lines sharing one piece of text).
|
||||
var times []int
|
||||
rest := line
|
||||
for {
|
||||
m := lineTimeRE.FindStringSubmatch(rest)
|
||||
if m == nil {
|
||||
break
|
||||
}
|
||||
times = append(times, parseTimeMs(m[1], m[2], m[3]))
|
||||
rest = rest[len(m[0]):]
|
||||
}
|
||||
if len(times) == 0 {
|
||||
continue // not a timed lyric line and not a recognized metadata tag
|
||||
}
|
||||
|
||||
text, words := parseWords(rest)
|
||||
for _, t := range times {
|
||||
p.Lines = append(p.Lines, Line{StartMs: t, Text: text, Words: words})
|
||||
if len(words) > 0 {
|
||||
p.Enhanced = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sortLines(p.Lines)
|
||||
return p
|
||||
}
|
||||
|
||||
func applyMetaTag(p *Parsed, key, value string) {
|
||||
switch key {
|
||||
case "la":
|
||||
p.Lang = value
|
||||
case "offset":
|
||||
if n, err := strconv.Atoi(strings.TrimPrefix(value, "+")); err == nil {
|
||||
p.OffsetMs = n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// parseWords splits an Enhanced-LRC line body on inline <mm:ss.xx> word
|
||||
// timestamps. If there are none, it returns the whole trimmed body as
|
||||
// plain text with no words — a normal (non-enhanced) LRC line.
|
||||
func parseWords(body string) (text string, words []Word) {
|
||||
matches := wordTimeRE.FindAllStringSubmatchIndex(body, -1)
|
||||
if len(matches) == 0 {
|
||||
return strings.TrimSpace(body), nil
|
||||
}
|
||||
|
||||
for i, m := range matches {
|
||||
wordStart := m[1] // end of the <time> tag itself
|
||||
wordEnd := len(body)
|
||||
if i+1 < len(matches) {
|
||||
wordEnd = matches[i+1][0]
|
||||
}
|
||||
wordText := strings.TrimSpace(body[wordStart:wordEnd])
|
||||
startMs := parseTimeMs(body[m[2]:m[3]], body[m[4]:m[5]], subOrEmpty(body, m[6], m[7]))
|
||||
if wordText != "" {
|
||||
words = append(words, Word{StartMs: startMs, Text: wordText})
|
||||
}
|
||||
}
|
||||
// Plain text rendering: words joined with a single space, matching
|
||||
// how the line reads without timing markup.
|
||||
parts := make([]string, 0, len(words))
|
||||
for _, w := range words {
|
||||
parts = append(parts, w.Text)
|
||||
}
|
||||
return strings.Join(parts, " "), words
|
||||
}
|
||||
|
||||
func subOrEmpty(s string, start, end int) string {
|
||||
if start < 0 || end < 0 {
|
||||
return ""
|
||||
}
|
||||
return s[start:end]
|
||||
}
|
||||
|
||||
func parseTimeMs(minStr, secStr, fracStr string) int {
|
||||
min, _ := strconv.Atoi(minStr)
|
||||
sec, _ := strconv.Atoi(secStr)
|
||||
frac := 0
|
||||
if fracStr != "" {
|
||||
// Fractional part may be 1-3 digits (tenths, hundredths, or
|
||||
// milliseconds); normalize to milliseconds.
|
||||
f, _ := strconv.Atoi(fracStr)
|
||||
switch len(fracStr) {
|
||||
case 1:
|
||||
frac = f * 100
|
||||
case 2:
|
||||
frac = f * 10
|
||||
default:
|
||||
frac = f
|
||||
}
|
||||
}
|
||||
return min*60*1000 + sec*1000 + frac
|
||||
}
|
||||
|
||||
func sortLines(lines []Line) {
|
||||
sort.SliceStable(lines, func(i, j int) bool { return lines[i].StartMs < lines[j].StartMs })
|
||||
}
|
||||
Reference in New Issue
Block a user