diff --git a/internal/lyrics/lrc.go b/internal/lyrics/lrc.go new file mode 100644 index 0000000..faac26b --- /dev/null +++ b/internal/lyrics/lrc.go @@ -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 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