Add LRC/Enhanced LRC parser (line and word-level timing, UTF-8 safe)

This commit is contained in:
2026-08-23 08:10:06 -05:00
parent 4a5add80b6
commit efedf1a6bf
2 changed files with 316 additions and 0 deletions
+162
View File
@@ -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 })
}
+154
View File
@@ -0,0 +1,154 @@
package lyrics
import "testing"
func TestParsePlainLRC(t *testing.T) {
content := `[la:en]
[00:12.34]First line
[00:15.00]Second line
[00:20.50]Third line`
p := Parse(content)
if p.Lang != "en" {
t.Errorf("Lang = %q, want en", p.Lang)
}
if p.Enhanced {
t.Error("Enhanced = true, want false for plain LRC")
}
if len(p.Lines) != 3 {
t.Fatalf("Lines = %v, want 3", p.Lines)
}
if p.Lines[0].StartMs != 12340 || p.Lines[0].Text != "First line" {
t.Errorf("Lines[0] = %+v", p.Lines[0])
}
if p.Lines[2].StartMs != 20500 {
t.Errorf("Lines[2].StartMs = %d, want 20500", p.Lines[2].StartMs)
}
}
func TestParseLinesSortedByTime(t *testing.T) {
// Deliberately out of order in the source file.
content := "[00:20.00]Later\n[00:05.00]Earlier\n[00:10.00]Middle"
p := Parse(content)
if len(p.Lines) != 3 {
t.Fatalf("Lines = %v, want 3", p.Lines)
}
if p.Lines[0].Text != "Earlier" || p.Lines[1].Text != "Middle" || p.Lines[2].Text != "Later" {
t.Errorf("lines not sorted by time: %+v", p.Lines)
}
}
func TestParseRepeatedTimestamps(t *testing.T) {
content := "[00:05.00][00:45.00]Chorus line"
p := Parse(content)
if len(p.Lines) != 2 {
t.Fatalf("Lines = %v, want 2 (one per timestamp)", p.Lines)
}
if p.Lines[0].Text != "Chorus line" || p.Lines[1].Text != "Chorus line" {
t.Errorf("both repeated lines should have the same text: %+v", p.Lines)
}
}
func TestParseEnhancedLRCWordTiming(t *testing.T) {
content := "[00:12.00]<00:12.00>Hello <00:12.50>world <00:13.00>test"
p := Parse(content)
if !p.Enhanced {
t.Fatal("Enhanced = false, want true for word-timed input")
}
if len(p.Lines) != 1 {
t.Fatalf("Lines = %v, want 1", p.Lines)
}
line := p.Lines[0]
if line.Text != "Hello world test" {
t.Errorf("Text = %q, want %q", line.Text, "Hello world test")
}
if len(line.Words) != 3 {
t.Fatalf("Words = %v, want 3", line.Words)
}
if line.Words[0].StartMs != 12000 || line.Words[0].Text != "Hello" {
t.Errorf("Words[0] = %+v", line.Words[0])
}
if line.Words[1].StartMs != 12500 || line.Words[1].Text != "world" {
t.Errorf("Words[1] = %+v", line.Words[1])
}
if line.Words[2].StartMs != 13000 || line.Words[2].Text != "test" {
t.Errorf("Words[2] = %+v", line.Words[2])
}
}
func TestParsePlainLRCHasNoWords(t *testing.T) {
// Regression guard for "do not fake enhanced timing from ordinary
// plain lyrics": a plain synced line must never synthesize word cues.
content := "[00:12.00]Just a normal line with several words"
p := Parse(content)
if p.Enhanced {
t.Fatal("Enhanced = true for a plain LRC line")
}
if len(p.Lines[0].Words) != 0 {
t.Fatalf("Words = %v, want none for a plain line", p.Lines[0].Words)
}
}
func TestParseUTF8Content(t *testing.T) {
// Japanese and emoji content, to exercise multi-byte UTF-8 handling
// (relevant later for byte-offset computation in the OpenSubsonic
// wire format).
content := "[00:01.00]こんにちは世界\n[00:05.00]🎵 music note 🎶"
p := Parse(content)
if len(p.Lines) != 2 {
t.Fatalf("Lines = %v, want 2", p.Lines)
}
if p.Lines[0].Text != "こんにちは世界" {
t.Errorf("Lines[0].Text = %q", p.Lines[0].Text)
}
if p.Lines[1].Text != "🎵 music note 🎶" {
t.Errorf("Lines[1].Text = %q", p.Lines[1].Text)
}
}
func TestParseEnhancedUTF8Words(t *testing.T) {
content := "[00:01.00]<00:01.00>日本語<00:01.50>のテスト"
p := Parse(content)
if len(p.Lines[0].Words) != 2 {
t.Fatalf("Words = %v, want 2", p.Lines[0].Words)
}
if p.Lines[0].Words[0].Text != "日本語" {
t.Errorf("Words[0].Text = %q", p.Lines[0].Words[0].Text)
}
if p.Lines[0].Words[1].Text != "のテスト" {
t.Errorf("Words[1].Text = %q", p.Lines[0].Words[1].Text)
}
}
func TestParseOffset(t *testing.T) {
p := Parse("[offset:+500]\n[00:10.00]Line")
if p.OffsetMs != 500 {
t.Errorf("OffsetMs = %d, want 500", p.OffsetMs)
}
}
func TestParseEmptyInput(t *testing.T) {
p := Parse("")
if len(p.Lines) != 0 {
t.Errorf("Lines = %v, want empty", p.Lines)
}
}
func TestParseIgnoresUnrecognizedLines(t *testing.T) {
content := "[ar:Some Artist]\n[ti:Some Title]\nplain text with no timestamp\n[00:01.00]Actual line"
p := Parse(content)
if len(p.Lines) != 1 {
t.Fatalf("Lines = %v, want 1 (junk lines skipped)", p.Lines)
}
}
func TestParseCentisecondsVsMilliseconds(t *testing.T) {
// [mm:ss.xx] (centiseconds, 2 digits) vs [mm:ss.xxx] (milliseconds, 3 digits).
p := Parse("[00:01.50]Two digit\n[00:02.500]Three digit")
if p.Lines[0].StartMs != 1500 {
t.Errorf("two-digit frac: StartMs = %d, want 1500", p.Lines[0].StartMs)
}
if p.Lines[1].StartMs != 2500 {
t.Errorf("three-digit frac: StartMs = %d, want 2500", p.Lines[1].StartMs)
}
}