mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
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:
parent
82fde00ecc
commit
42dfdf49da
@ -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) {
|
||||
|
||||
@ -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())
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user