feat(lyrics): support [bg:] tag and skip unknown tags in LRC files (#5966)

Unknown [name:value] tag lines (e.g. [al:], [by:]) were being glued onto the previous lyric line, producing junk cues like "\n[bg: " and stretching the line's timing. Now any unrecognized tag line is skipped whole, and the non-standard [bg:] background-vocal tag is parsed into cues attached to the preceding line under a bg agent, using the same agents representation the TTML parser emits for Apple-style background vocals.
This commit is contained in:
Deluan Quintão 2026-08-16 10:18:10 -04:00 committed by GitHub
parent 82fde00ecc
commit 42dfdf49da
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 127 additions and 19 deletions

View File

@ -21,6 +21,12 @@ var (
timeRegex = regexp.MustCompile(timeRegexString)
lrcIdRegex = regexp.MustCompile(`\[(ar|ti|offset|lang):([^]]+)]`)
// Non-standard background-vocal tag: [bg: <mm:ss.mm>word...] attaches to the preceding line
bgTagRegex = regexp.MustCompile(`^\[bg:(.*)]$`)
// Any other [name:value] line is a tag we don't support (e.g. [al:], [by:]) and must be skipped whole
unknownTagRegex = regexp.MustCompile(`^\[[A-Za-z][^]:]*:[^]]*]`)
// Enhanced LRC: inline word-level timing markers like <00:12.34>
enhancedLRCTimeString = `<([0-9]{1,2}:)?([0-9]{1,2}):([0-9]{1,2})(\.[0-9]{1,3})?>`
enhancedLRCRegex = regexp.MustCompile(enhancedLRCTimeString)
@ -40,7 +46,26 @@ func parseLRC(language, text string) (*Lyrics, error) {
priorLine := ""
validLine := false
repeated := false
hasBg := false
var timestamps []int64
var pendingBg []string
flushLine := func() {
value, cues := parseEnhancedLine(priorLine)
var merged bool
value, cues, merged = mergeBgLayers(value, cues, pendingBg)
hasBg = hasBg || merged
for idx := range timestamps {
startCopy := timestamps[idx]
structuredLines = append(structuredLines, Line{
Start: &startCopy,
Value: value,
Cue: shiftELRCCues(cues, timestamps[idx]-timestamps[0]),
})
}
timestamps = nil
pendingBg = nil
}
for _, line := range lines {
line := strings.TrimSpace(line)
@ -77,6 +102,17 @@ func parseLRC(language, text string) (*Lyrics, error) {
continue
}
if bgMatch := bgTagRegex.FindStringSubmatch(line); bgMatch != nil {
if validLine {
pendingBg = append(pendingBg, bgMatch[1])
}
continue
}
if unknownTagRegex.MatchString(line) {
continue
}
times := timeRegex.FindAllStringSubmatchIndex(line, -1)
if len(times) > 1 {
repeated = true
@ -92,16 +128,7 @@ func parseLRC(language, text string) (*Lyrics, error) {
}
if validLine {
value, baseCues := parseEnhancedLine(priorLine)
for idx := range timestamps {
startCopy := timestamps[idx]
structuredLines = append(structuredLines, Line{
Start: &startCopy,
Value: value,
Cue: shiftELRCCues(baseCues, timestamps[idx]-timestamps[0]),
})
}
timestamps = nil
flushLine()
}
end := 0
@ -143,15 +170,11 @@ func parseLRC(language, text string) (*Lyrics, error) {
}
if validLine {
value, baseCues := parseEnhancedLine(priorLine)
for idx := range timestamps {
startCopy := timestamps[idx]
structuredLines = append(structuredLines, Line{
Start: &startCopy,
Value: value,
Cue: shiftELRCCues(baseCues, timestamps[idx]-timestamps[0]),
})
}
flushLine()
}
if hasBg {
fillMainAgentID(structuredLines)
}
// If there are repeated values, there is no guarantee that they are in order
@ -170,9 +193,51 @@ func parseLRC(language, text string) (*Lyrics, error) {
Offset: offset,
Synced: synced,
}
if hasBg {
lyrics.Agents = []Agent{
{ID: "main", Role: "main"},
{ID: backgroundAgentID("main"), Role: "bg"},
}
}
return &lyrics, nil
}
// mergeBgLayers appends parsed [bg:] content to a line's value and cues as a
// background agent layer, mirroring the TTML background-vocal representation.
func mergeBgLayers(value string, cues []Cue, bgLines []string) (string, []Cue, bool) {
merged := false
for _, bg := range bgLines {
bgValue, bgCues := parseEnhancedLine(bg)
if len(bgCues) == 0 {
continue
}
offset := 0
if value != "" {
offset = len(value) + 1
value += " "
}
value += bgValue
for _, c := range bgCues {
c.ByteStart += offset
c.ByteEnd += offset
c.AgentID = backgroundAgentID("main")
cues = append(cues, c)
}
merged = true
}
return value, cues, merged
}
func fillMainAgentID(lines []Line) {
for i := range lines {
for j := range lines[i].Cue {
if lines[i].Cue[j].AgentID == "" {
lines[i].Cue[j].AgentID = "main"
}
}
}
}
// parseEnhancedLine extracts word-level timing cues from Enhanced LRC inline markers
// and computes UTF-8 byte offsets against the final stripped line value.
func parseEnhancedLine(text string) (string, []Cue) {

View File

@ -147,6 +147,49 @@ var _ = Describe("parseLRC", func() {
}))
})
It("should skip unknown tag lines instead of merging them into the prior line", func() {
lyrics, err := parseLRC("xxx", "[00:01.00]<00:01.00>Some <00:01.50>lyrics\n[by: someone]\n[al: An album]\n[00:03.00]Next")
Expect(err).ToNot(HaveOccurred())
Expect(lyrics.Synced).To(BeTrue())
Expect(lyrics.Line).To(HaveLen(2))
t1000, t1500, t3000 := int64(1000), int64(1500), int64(3000)
Expect(lyrics.Line[0].Value).To(Equal("Some lyrics"))
Expect(lyrics.Line[0].Cue).To(Equal([]Cue{
{Start: &t1000, End: &t1500, Value: "Some ", ByteStart: 0, ByteEnd: 4},
{Start: &t1500, End: &t3000, Value: "lyrics", ByteStart: 5, ByteEnd: 10},
}))
Expect(lyrics.Line[1].Value).To(Equal("Next"))
})
It("should attach [bg:] background vocals to the previous line as a bg agent layer", func() {
lyrics, err := parseLRC("xxx", "[00:52.00]<00:52.00>Main <00:52.50>line\n[bg: <00:53.00>Okay<00:53.50>]\n[00:55.00]Next")
Expect(err).ToNot(HaveOccurred())
Expect(lyrics.Agents).To(Equal([]Agent{
{ID: "main", Role: "main"},
{ID: "__nd_bg__|main", Role: "bg"},
}))
Expect(lyrics.Line).To(HaveLen(2))
line := lyrics.Line[0]
Expect(line.Value).To(Equal("Main line Okay"))
Expect(line.Cue).To(Equal([]Cue{
{Start: new(int64(52000)), End: new(int64(52500)), Value: "Main ", ByteStart: 0, ByteEnd: 4, AgentID: "main"},
{Start: new(int64(52500)), End: new(int64(55000)), Value: "line", ByteStart: 5, ByteEnd: 8, AgentID: "main"},
{Start: new(int64(53000)), End: new(int64(53500)), Value: "Okay", ByteStart: 10, ByteEnd: 13, AgentID: "__nd_bg__|main"},
}))
Expect(lyrics.Line[1].Value).To(Equal("Next"))
})
It("should skip a [bg:] line that has no inline timing", func() {
lyrics, err := parseLRC("xxx", "[00:01.00]Hi\n[bg: untimed]\n[00:03.00]Next")
Expect(err).ToNot(HaveOccurred())
Expect(lyrics.Agents).To(BeNil())
Expect(lyrics.Line).To(HaveLen(2))
Expect(lyrics.Line[0].Value).To(Equal("Hi"))
Expect(lyrics.Line[1].Value).To(Equal("Next"))
})
It("should handle mixed Enhanced and plain LRC lines", func() {
lyrics, err := parseLRC("xxx", "[00:01.00]<00:01.00>Some <00:01.50>lyrics\n[00:03.00]Plain line\n[00:05.00]<00:05.00>More <00:05.50>words")
Expect(err).ToNot(HaveOccurred())