address feedback

This commit is contained in:
Rufei Zhou 2026-05-31 21:51:31 -07:00
parent 3096a09a1b
commit 6db89b790d
5 changed files with 119 additions and 13 deletions

View File

@ -228,7 +228,10 @@ func ToLyrics(language, text string) (*Lyrics, error) {
// flushPriorLine emits one CueLine per accumulated timestamp, copying the
// shared text/cue data into each. The last "Repeated" use-case is preserved
// (a single text repeats at multiple timestamps).
// (a single text repeats at multiple timestamps). For repeated lines with
// ELRC word timings, cues carry absolute timestamps anchored to the first
// occurrence (timestamps[0]); each subsequent occurrence is shifted by
// timestamps[idx]-timestamps[0] so its cues point at the correct moment,
func flushPriorLine(cueLines *[]CueLine, timestamps []int64, text string, cues []Cue) {
trimmed := strings.TrimSpace(text)
for idx := range timestamps {
@ -238,11 +241,20 @@ func flushPriorLine(cueLines *[]CueLine, timestamps []int64, text string, cues [
Start: &startCopy,
Value: trimmed,
}
// Cues are only meaningful for the first occurrence in a repeat; copy
// them onto each cueLine so each independent index has its own cue list.
if len(cues) > 0 {
offset := timestamps[idx] - timestamps[0]
cl.Cue = make([]Cue, len(cues))
copy(cl.Cue, cues)
for i, c := range cues {
cl.Cue[i] = Cue{Value: c.Value}
if c.Start != nil {
s := *c.Start + offset
cl.Cue[i].Start = &s
}
if c.End != nil {
e := *c.End + offset
cl.Cue[i].End = &e
}
}
}
*cueLines = append(*cueLines, cl)
}

View File

@ -141,5 +141,31 @@ var _ = Describe("ToLyrics", func() {
// Last cue has no inferred end.
Expect(cl.Cue[1].End).To(BeNil())
})
It("shifts cue timestamps for each repeated line occurrence and allocates fresh pointers", func() {
lyrics, err := ToLyrics("eng", "[00:10.00][00:30.00]<00:10.10>Hello <00:10.50>world")
Expect(err).ToNot(HaveOccurred())
Expect(lyrics.CueLine).To(HaveLen(2))
first := lyrics.CueLine[0]
Expect(*first.Start).To(Equal(int64(10000)))
Expect(first.Cue).To(HaveLen(2))
Expect(*first.Cue[0].Start).To(Equal(int64(10100)))
Expect(*first.Cue[1].Start).To(Equal(int64(10500)))
Expect(*first.Cue[0].End).To(Equal(int64(10500)))
Expect(first.Cue[1].End).To(BeNil())
second := lyrics.CueLine[1]
Expect(*second.Start).To(Equal(int64(30000)))
Expect(second.Cue).To(HaveLen(2))
Expect(*second.Cue[0].Start).To(Equal(int64(30100)))
Expect(*second.Cue[1].Start).To(Equal(int64(30500)))
Expect(*second.Cue[0].End).To(Equal(int64(30500)))
Expect(second.Cue[1].End).To(BeNil())
Expect(second.Cue[0].Start).ToNot(BeIdenticalTo(first.Cue[0].Start))
Expect(second.Cue[1].Start).ToNot(BeIdenticalTo(first.Cue[1].Start))
Expect(second.Cue[0].End).ToNot(BeIdenticalTo(first.Cue[0].End))
})
})
})

View File

@ -60,11 +60,11 @@ func ParseLyricsfile(text string) (*Lyrics, error) {
}
type lyricsfileDocument struct {
Version string `yaml:"version"`
Metadata lyricsfileMetadata `yaml:"metadata"`
Lines []lyricsfileLineEntry `yaml:"lines"`
Plain string `yaml:"plain"`
Extra map[string]yaml.Node `yaml:",inline"`
Version string `yaml:"version"`
Metadata lyricsfileMetadata `yaml:"metadata"`
Lines []lyricsfileLineEntry `yaml:"lines"`
Plain string `yaml:"plain"`
Extra map[string]yaml.Node `yaml:",inline"`
}
type lyricsfileMetadata struct {
@ -150,13 +150,26 @@ func buildLyricsfileCueLines(entries []lyricsfileLineEntry) ([]CueLine, []Agent)
}
startCopy := entry.StartMs
cues := wordsToCues(entry.Words)
// Cue.byteStart/byteEnd are positions in cueLine.value per the
// OpenSubsonic v2 spec, so when cues are present we reconstruct value
// from them rather than trust entry.Text (the Lyricsfile spec only
// requires word.text to "approximate" line.text).
value := entry.Text
if len(cues) > 0 {
var sb strings.Builder
for _, c := range cues {
sb.WriteString(c.Value)
}
value = sb.String()
}
cl := CueLine{
Index: currentIndex,
Start: &startCopy,
End: ends[i],
Value: entry.Text,
Value: value,
AgentID: fmt.Sprintf("voice-%d", voiceID),
Cue: wordsToCues(entry.Words),
Cue: cues,
}
cueLines = append(cueLines, cl)
@ -217,4 +230,3 @@ func wordsToCues(words []lyricsfileWordEntry) []Cue {
}
return cues
}

56
model/lyricsfile_test.go Normal file
View File

@ -0,0 +1,56 @@
package model_test
import (
. "github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("ParseLyricsfile", func() {
It("rebuilds CueLine.Value from word cues so byteStart/byteEnd remain valid offsets", func() {
yaml := `version: '1.0'
metadata:
title: 'Punctuated'
artist: 'Test Artist'
lines:
- text: 'Hello, world!'
start_ms: 1000
end_ms: 3000
words:
- text: 'Hello '
start_ms: 1000
end_ms: 2000
- text: 'world'
start_ms: 2000
end_ms: 3000
`
lyrics, err := ParseLyricsfile(yaml)
Expect(err).ToNot(HaveOccurred())
Expect(lyrics.CueLine).To(HaveLen(1))
cl := lyrics.CueLine[0]
Expect(cl.Cue).To(HaveLen(2))
Expect(cl.Cue[0].Value).To(Equal("Hello "))
Expect(cl.Cue[1].Value).To(Equal("world"))
Expect(cl.Value).To(Equal("Hello world"))
})
It("keeps entry.Text on CueLine.Value when the line has no word cues", func() {
yaml := `version: '1.0'
metadata:
title: 'Line only'
artist: 'Test Artist'
lines:
- text: 'Hello, world!'
start_ms: 1000
end_ms: 3000
`
lyrics, err := ParseLyricsfile(yaml)
Expect(err).ToNot(HaveOccurred())
Expect(lyrics.CueLine).To(HaveLen(1))
cl := lyrics.CueLine[0]
Expect(cl.Cue).To(BeEmpty())
Expect(cl.Value).To(Equal("Hello, world!"))
})
})

View File

@ -578,7 +578,7 @@ func buildV2CueLines(cueLines []model.CueLine) []responses.CueLine {
}
// buildCue maps each model.Cue to one responses.Cue with inclusive UTF-8
// ByteStart/ByteEnd offsets into cl.Value.
// ByteStart/ByteEnd offsets into cl.Value.
func buildCue(cl model.CueLine) []responses.Cue {
if len(cl.Cue) == 0 {
return nil