diff --git a/model/lyrics.go b/model/lyrics.go index fbc7a55ef..8ec9fc408 100644 --- a/model/lyrics.go +++ b/model/lyrics.go @@ -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) } diff --git a/model/lyrics_test.go b/model/lyrics_test.go index 53fe48ec9..1fa7fe526 100644 --- a/model/lyrics_test.go +++ b/model/lyrics_test.go @@ -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)) + }) }) }) diff --git a/model/lyricsfile.go b/model/lyricsfile.go index a002b7b20..727bcc93b 100644 --- a/model/lyricsfile.go +++ b/model/lyricsfile.go @@ -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 } - diff --git a/model/lyricsfile_test.go b/model/lyricsfile_test.go new file mode 100644 index 000000000..af1b261b9 --- /dev/null +++ b/model/lyricsfile_test.go @@ -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!")) + }) +}) diff --git a/server/subsonic/helpers.go b/server/subsonic/helpers.go index 21e25b59f..c43965355 100644 --- a/server/subsonic/helpers.go +++ b/server/subsonic/helpers.go @@ -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