// 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