diff --git a/conf/configuration.go b/conf/configuration.go index 219b7b0bd..2ae6e84ca 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -776,7 +776,7 @@ func setViperDefaults() { viper.SetDefault("artistartpriority", "artist.*, album/artist.*, external") viper.SetDefault("artistimagefolder", "") viper.SetDefault("discartpriority", "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded") - viper.SetDefault("lyricspriority", ".ttml,.elrc,.lrc,.srt,.txt,embedded") + viper.SetDefault("lyricspriority", ".ttml,.yaml,.yml,.elrc,.lrc,.srt,.txt,embedded") viper.SetDefault("enablegravatar", false) viper.SetDefault("enablefavourites", true) viper.SetDefault("enablestarrating", true) diff --git a/core/lyrics/sources.go b/core/lyrics/sources.go index 7586c944f..aa9a6c765 100644 --- a/core/lyrics/sources.go +++ b/core/lyrics/sources.go @@ -51,6 +51,12 @@ func fromExternalFile(ctx context.Context, mf *model.MediaFile, suffix string) ( log.Error(ctx, "error parsing srt external file", "path", externalLyric, err) return nil, err } + case strings.EqualFold(suffix, ".yaml"), strings.EqualFold(suffix, ".yml"): + list, err = model.ParseLyricsfile(string(contents)) + if err != nil { + log.Error(ctx, "error parsing lyricsfile external file", "path", externalLyric, err) + return nil, err + } default: lyrics, err := model.ToLyrics("xxx", string(contents)) if err != nil { diff --git a/core/lyrics/sources_test.go b/core/lyrics/sources_test.go index 7ca4a0a45..000db0a1e 100644 --- a/core/lyrics/sources_test.go +++ b/core/lyrics/sources_test.go @@ -312,5 +312,98 @@ var _ = Describe("sources", func() { Expect(lyrics[0].Line[1].Start).To(Equal(ptr(int64(22801)))) Expect(lyrics[0].Line[1].Value).To(Equal("UTF16 line two")) }) + + It("should return Lyricsfile YAML lines with inferred end timestamps", func() { + mf := model.MediaFile{Path: "tests/fixtures/test.mp3"} + lyrics, err := fromExternalFile(ctx, &mf, ".yaml") + + Expect(err).To(BeNil()) + Expect(lyrics).To(Equal(model.LyricList{ + model.Lyrics{ + DisplayArtist: "Test Artist", + DisplayTitle: "Sample Track", + Kind: "main", + Lang: "eng", + Line: []model.Line{ + {Start: ptr(int64(18800)), End: ptr(int64(22801)), Value: "We're no strangers to love"}, + {Start: ptr(int64(22801)), Value: "You know the rules and so do I"}, + }, + Offset: ptr(int64(-100)), + Synced: true, + }, + })) + }) + + It("should return Lyricsfile YAML word cues with inclusive byte offsets", func() { + mf := model.MediaFile{Path: "tests/fixtures/test-words.mp3"} + lyrics, err := fromExternalFile(ctx, &mf, ".yaml") + + Expect(err).To(BeNil()) + Expect(lyrics).To(HaveLen(1)) + Expect(lyrics[0].DisplayArtist).To(Equal("Test Artist")) + Expect(lyrics[0].DisplayTitle).To(Equal("Karaoke Test")) + Expect(lyrics[0].Kind).To(Equal("main")) + Expect(lyrics[0].Lang).To(Equal("eng")) + Expect(lyrics[0].Synced).To(BeTrue()) + Expect(lyrics[0].Agents).To(BeNil()) + Expect(lyrics[0].Line).To(HaveLen(1)) + + line := lyrics[0].Line[0] + Expect(line.Start).To(Equal(ptr(int64(1000)))) + Expect(line.End).To(Equal(ptr(int64(3000)))) + Expect(line.Value).To(Equal("Hello world")) + Expect(line.Cue).To(HaveLen(2)) + + Expect(line.Cue[0].Start).To(Equal(ptr(int64(1000)))) + Expect(line.Cue[0].End).To(Equal(ptr(int64(1500)))) + Expect(line.Cue[0].Value).To(Equal("Hello ")) + Expect(line.Cue[0].ByteStart).To(Equal(0)) + Expect(line.Cue[0].ByteEnd).To(Equal(5)) + Expect(line.Cue[0].AgentID).To(Equal("")) + + Expect(line.Cue[1].Start).To(Equal(ptr(int64(1500)))) + Expect(line.Cue[1].End).To(Equal(ptr(int64(3000)))) + Expect(line.Cue[1].Value).To(Equal("world")) + Expect(line.Cue[1].ByteStart).To(Equal(6)) + Expect(line.Cue[1].ByteEnd).To(Equal(10)) + Expect(line.Cue[1].AgentID).To(Equal("")) + }) + + It("should synthesise voice agents for overlapping Lyricsfile YAML lines", func() { + mf := model.MediaFile{Path: "tests/fixtures/test-overlapping.mp3"} + lyrics, err := fromExternalFile(ctx, &mf, ".yaml") + + Expect(err).To(BeNil()) + Expect(lyrics).To(HaveLen(1)) + Expect(lyrics[0].Agents).To(Equal([]model.Agent{ + {ID: "voice-0", Role: "main"}, + {ID: "voice-1", Role: "voice"}, + })) + Expect(lyrics[0].Line).To(HaveLen(2)) + + Expect(lyrics[0].Line[0].Value).To(Equal("Lead vocal")) + Expect(lyrics[0].Line[0].Cue).To(HaveLen(2)) + Expect(lyrics[0].Line[0].Cue[0].AgentID).To(Equal("voice-0")) + Expect(lyrics[0].Line[0].Cue[1].AgentID).To(Equal("voice-0")) + + Expect(lyrics[0].Line[1].Value).To(Equal("echo")) + Expect(lyrics[0].Line[1].Cue).To(HaveLen(1)) + Expect(lyrics[0].Line[1].Cue[0].AgentID).To(Equal("voice-1")) + }) + + It("should emit empty Line[] with Synced=false for instrumental Lyricsfile YAML", func() { + mf := model.MediaFile{Path: "tests/fixtures/test-instrumental.mp3"} + lyrics, err := fromExternalFile(ctx, &mf, ".yaml") + + Expect(err).To(BeNil()) + Expect(lyrics).To(HaveLen(1)) + Expect(lyrics[0].Kind).To(Equal("main")) + Expect(lyrics[0].Lang).To(Equal("eng")) + Expect(lyrics[0].DisplayArtist).To(Equal("Composer")) + Expect(lyrics[0].DisplayTitle).To(Equal("Solo Piano")) + Expect(lyrics[0].Synced).To(BeFalse()) + Expect(lyrics[0].Line).To(BeEmpty()) + Expect(lyrics[0].Agents).To(BeNil()) + }) }) }) diff --git a/model/lyrics.go b/model/lyrics.go index f9f21b873..527a8cc91 100644 --- a/model/lyrics.go +++ b/model/lyrics.go @@ -129,12 +129,13 @@ func ToLyrics(language, text string) (*Lyrics, error) { } if validLine { + value, baseCues := parseEnhancedLine(priorLine) for idx := range timestamps { - value, cues := parseEnhancedLine(priorLine) + startCopy := timestamps[idx] structuredLines = append(structuredLines, Line{ - Start: ×tamps[idx], + Start: &startCopy, Value: value, - Cue: cues, + Cue: shiftELRCCues(baseCues, timestamps[idx]-timestamps[0]), }) } timestamps = nil @@ -179,12 +180,13 @@ func ToLyrics(language, text string) (*Lyrics, error) { } if validLine { + value, baseCues := parseEnhancedLine(priorLine) for idx := range timestamps { - value, cues := parseEnhancedLine(priorLine) + startCopy := timestamps[idx] structuredLines = append(structuredLines, Line{ - Start: ×tamps[idx], + Start: &startCopy, Value: value, - Cue: cues, + Cue: shiftELRCCues(baseCues, timestamps[idx]-timestamps[0]), }) } } @@ -313,6 +315,31 @@ func stripEnhancedMarkers(text string) string { return enhancedLRCRegex.ReplaceAllString(text, "") } +// shiftELRCCues returns a deep copy of baseCues with each cue's Start/End +// timestamps shifted by offsetMs. Inline ELRC word markers parse to absolute +// timestamps anchored at the line's first occurrence, so repeated-line LRC +// inputs of the form `[t0][t1]...` must shift the cues by (t1-t0) for the +// second occurrence to point at the correct moment. Returned *int64 pointers +// are freshly allocated so the input slice is never aliased into the result. +func shiftELRCCues(baseCues []Cue, offsetMs int64) []Cue { + if len(baseCues) == 0 { + return nil + } + out := make([]Cue, len(baseCues)) + for i, c := range baseCues { + out[i] = c + if c.Start != nil { + s := *c.Start + offsetMs + out[i].Start = &s + } + if c.End != nil { + e := *c.End + offsetMs + out[i].End = &e + } + } + return out +} + func parseTime(line string, match []int) (int64, error) { var hours, millis int64 var err error diff --git a/model/lyrics_test.go b/model/lyrics_test.go index 21bdf0e3d..abeaf4da2 100644 --- a/model/lyrics_test.go +++ b/model/lyrics_test.go @@ -198,6 +198,35 @@ var _ = Describe("ToLyrics", func() { {Start: &t1600, Value: "tonight", ByteStart: 11, ByteEnd: 17}, })) }) + + It("should shift inline ELRC word timestamps for each repeated line occurrence", func() { + lyrics, err := ToLyrics("xxx", "[00:10.00][00:30.00]<00:10.10>Hello <00:10.50>world") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Line).To(HaveLen(2)) + + t10000 := int64(10000) + t10100 := int64(10100) + t10500 := int64(10500) + t30000 := int64(30000) + t30100 := int64(30100) + t30500 := int64(30500) + + Expect(lyrics.Line[0].Start).To(Equal(&t10000)) + Expect(lyrics.Line[0].End).To(Equal(&t30000)) + Expect(lyrics.Line[0].Value).To(Equal("Hello world")) + Expect(lyrics.Line[0].Cue).To(Equal([]Cue{ + {Start: &t10100, End: &t10500, Value: "Hello ", ByteStart: 0, ByteEnd: 5}, + {Start: &t10500, End: &t30000, Value: "world", ByteStart: 6, ByteEnd: 10}, + })) + + Expect(lyrics.Line[1].Start).To(Equal(&t30000)) + Expect(lyrics.Line[1].End).To(Equal(&t30500)) + Expect(lyrics.Line[1].Value).To(Equal("Hello world")) + Expect(lyrics.Line[1].Cue).To(Equal([]Cue{ + {Start: &t30100, Value: "Hello ", ByteStart: 0, ByteEnd: 5}, + {Start: &t30500, Value: "world", ByteStart: 6, ByteEnd: 10}, + })) + }) }) var _ = Describe("NormalizeCueLines", func() { diff --git a/model/lyricsfile.go b/model/lyricsfile.go new file mode 100644 index 000000000..781d8c5c8 --- /dev/null +++ b/model/lyricsfile.go @@ -0,0 +1,247 @@ +package model + +import ( + "fmt" + "strings" + + "github.com/navidrome/navidrome/utils/str" + "gopkg.in/yaml.v3" +) + +// ParseLyricsfile parses a LRCLIB Lyricsfile YAML document +// (see https://github.com/tranxuanthang/lrcget/blob/main/LYRICSFILE_CONCEPT.md) +// into a model.LyricList containing a single main Lyrics entry. Returns +// (nil, nil) when the input parses as YAML but does not look like a +// Lyricsfile (no version, no metadata, no lines, no instrumental flag) +// +// When the source contains per-word timing via lines[].words[], each word +// becomes a model.Cue with inclusive UTF-8 byte offsets into Line.Value, and +// overlapping lines are attributed to synthetic voice agents via lowest-free +// voice ID assignment so the OpenSubsonic v2 enhanced response can split +// parallel vocals. +func ParseLyricsfile(text string) (LyricList, error) { + var doc lyricsfileDocument + dec := yaml.NewDecoder(strings.NewReader(text)) + dec.KnownFields(false) + if err := dec.Decode(&doc); err != nil { + return nil, fmt.Errorf("not a valid Lyricsfile YAML: %w", err) + } + + if doc.Version == "" && doc.Metadata.isEmpty() && len(doc.Lines) == 0 { + return nil, nil + } + + lyrics := Lyrics{ + DisplayArtist: str.SanitizeText(doc.Metadata.Artist), + DisplayTitle: str.SanitizeText(doc.Metadata.Title), + Lang: normalizeLyricsfileLang(doc.Metadata.Language), + Kind: lyricsfileKindMain, + } + if doc.Metadata.OffsetMs != 0 { + off := doc.Metadata.OffsetMs + lyrics.Offset = &off + } + + if doc.Metadata.Instrumental || len(doc.Lines) == 0 { + return LyricList{NormalizeLyrics(lyrics)}, nil + } + + lines, agents := buildLyricsfileLines(doc.Lines) + lyrics.Line = lines + lyrics.Agents = agents + lyrics.Synced = true + return LyricList{NormalizeLyrics(lyrics)}, nil +} + +const lyricsfileKindMain = "main" + +type lyricsfileDocument struct { + Version string `yaml:"version"` + Metadata lyricsfileMetadata `yaml:"metadata"` + Lines []lyricsfileLineEntry `yaml:"lines"` + Plain string `yaml:"plain"` +} + +type lyricsfileMetadata struct { + Title string `yaml:"title"` + Artist string `yaml:"artist"` + Album string `yaml:"album"` + DurationMs int64 `yaml:"duration_ms"` + OffsetMs int64 `yaml:"offset_ms"` + Language string `yaml:"language"` + Instrumental bool `yaml:"instrumental"` +} + +func (m lyricsfileMetadata) isEmpty() bool { + return m.Title == "" && m.Artist == "" && m.Album == "" && + m.DurationMs == 0 && m.OffsetMs == 0 && m.Language == "" && !m.Instrumental +} + +type lyricsfileLineEntry struct { + Text string `yaml:"text"` + StartMs int64 `yaml:"start_ms"` + EndMs *int64 `yaml:"end_ms"` + Words []lyricsfileWordEntry `yaml:"words"` +} + +type lyricsfileWordEntry struct { + Text string `yaml:"text"` + StartMs int64 `yaml:"start_ms"` + EndMs *int64 `yaml:"end_ms"` +} + +// buildLyricsfileLines converts YAML line entries to model.Line entries with +// per-cue AgentIDs assigned by streaming overlap clustering (lowest-free +// voice ID). The Agents slice is emitted only when at least one cue carries +// attribution AND more than one voice is used; otherwise AgentIDs are +// stripped so the wire shape stays simple per the OpenSubsonic spec rule +// "agents should not be emitted without cueLine data". +func buildLyricsfileLines(entries []lyricsfileLineEntry) ([]Line, []Agent) { + if len(entries) == 0 { + return nil, nil + } + + // Resolved end timestamps per entry: explicit end_ms if present, otherwise + // the next entry's start. The last entry's end stays nil. + ends := make([]*int64, len(entries)) + for i := range entries { + if entries[i].EndMs != nil { + v := *entries[i].EndMs + ends[i] = &v + } else if i+1 < len(entries) { + v := entries[i+1].StartMs + ends[i] = &v + } + } + + active := map[int]int64{} + maxVoice := -1 + anyCues := false + lines := make([]Line, 0, len(entries)) + + for i, entry := range entries { + for vID, vEnd := range active { + if vEnd <= entry.StartMs { + delete(active, vID) + } + } + + voiceID := 0 + for { + if _, busy := active[voiceID]; !busy { + break + } + voiceID++ + } + if voiceID > maxVoice { + maxVoice = voiceID + } + + agentID := fmt.Sprintf("voice-%d", voiceID) + cues, value := wordsToLineCues(entry, agentID) + if len(cues) > 0 { + anyCues = true + } + + startMs := entry.StartMs + line := Line{ + Start: &startMs, + End: ends[i], + Value: value, + Cue: cues, + } + lines = append(lines, line) + + var endMs int64 + if ends[i] != nil { + endMs = *ends[i] + } else { + endMs = entry.StartMs + } + active[voiceID] = endMs + } + + // Monophonic source, or attribution that has nowhere to land: emit no + // agents and strip per-cue AgentIDs to keep the wire shape simple. + if maxVoice <= 0 || !anyCues { + for i := range lines { + for j := range lines[i].Cue { + lines[i].Cue[j].AgentID = "" + } + } + return lines, nil + } + + agents := make([]Agent, 0, maxVoice+1) + for v := 0; v <= maxVoice; v++ { + role := "voice" + if v == 0 { + role = "main" + } + agents = append(agents, Agent{ + ID: fmt.Sprintf("voice-%d", v), + Role: role, + }) + } + return lines, agents +} + +// wordsToLineCues converts a Lyricsfile line entry's words[] into model.Cue +// entries with inclusive UTF-8 byte offsets into the reconstructed line +// value. The line value is built from cue text concatenation rather than +// trusting entry.Text, because the Lyricsfile spec only requires word.text +// to "approximate" line.text - byte offsets must always land inside +// Line.Value. +func wordsToLineCues(entry lyricsfileLineEntry, agentID string) ([]Cue, string) { + if len(entry.Words) == 0 { + return nil, str.SanitizeText(entry.Text) + } + + var sb strings.Builder + for _, w := range entry.Words { + sb.WriteString(w.Text) + } + lineValue := sb.String() + + cues := make([]Cue, len(entry.Words)) + cursor := 0 + for i, w := range entry.Words { + valueBytes := len(w.Text) + bs := cursor + be := bs + if valueBytes > 0 { + be = bs + valueBytes - 1 + cursor = be + 1 + } + + s := w.StartMs + cue := Cue{ + Start: &s, + Value: w.Text, + ByteStart: bs, + ByteEnd: be, + AgentID: agentID, + } + if w.EndMs != nil { + e := *w.EndMs + cue.End = &e + } + cues[i] = cue + } + + for i := 0; i < len(cues)-1; i++ { + if cues[i].End == nil && cues[i+1].Start != nil { + v := *cues[i+1].Start + cues[i].End = &v + } + } + return cues, lineValue +} + +func normalizeLyricsfileLang(language string) string { + language = strings.ToLower(strings.TrimSpace(language)) + if language == "" { + return "xxx" + } + return language +} diff --git a/model/lyricsfile_test.go b/model/lyricsfile_test.go new file mode 100644 index 000000000..48cce55d4 --- /dev/null +++ b/model/lyricsfile_test.go @@ -0,0 +1,205 @@ +package model_test + +import ( + . "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("ParseLyricsfile", func() { + It("returns nil,nil for YAML that does not look like a Lyricsfile", func() { + lyrics, err := ParseLyricsfile("hello: world\n") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics).To(BeNil()) + }) + + It("returns an error for invalid YAML", func() { + _, err := ParseLyricsfile("not: valid: yaml: [") + Expect(err).To(HaveOccurred()) + }) + + It("parses line-level metadata without cues", func() { + input := `version: '1.0' +metadata: + title: 'Sample Track' + artist: 'Test Artist' + language: 'eng' + offset_ms: -100 +lines: + - text: "We're no strangers to love" + start_ms: 18800 + - text: "You know the rules and so do I" + start_ms: 22801 +` + lyrics, err := ParseLyricsfile(input) + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics).To(HaveLen(1)) + + l := lyrics[0] + Expect(l.Kind).To(Equal("main")) + Expect(l.Lang).To(Equal("eng")) + Expect(l.DisplayArtist).To(Equal("Test Artist")) + Expect(l.DisplayTitle).To(Equal("Sample Track")) + Expect(l.Synced).To(BeTrue()) + Expect(l.Offset).ToNot(BeNil()) + Expect(*l.Offset).To(Equal(int64(-100))) + Expect(l.Agents).To(BeNil()) + + Expect(l.Line).To(HaveLen(2)) + Expect(*l.Line[0].Start).To(Equal(int64(18800))) + Expect(l.Line[0].End).ToNot(BeNil()) + Expect(*l.Line[0].End).To(Equal(int64(22801))) + Expect(l.Line[0].Value).To(Equal("We're no strangers to love")) + Expect(l.Line[0].Cue).To(BeNil()) + + Expect(*l.Line[1].Start).To(Equal(int64(22801))) + Expect(l.Line[1].End).To(BeNil()) + Expect(l.Line[1].Value).To(Equal("You know the rules and so do I")) + Expect(l.Line[1].Cue).To(BeNil()) + }) + + It("produces word cues with inclusive UTF-8 byte offsets for monophonic word data", func() { + input := `version: '1.0' +metadata: + title: 'Karaoke' + artist: 'Singer' + language: 'eng' +lines: + - text: "Hello world" + start_ms: 1000 + end_ms: 3000 + words: + - text: "Hello " + start_ms: 1000 + end_ms: 1500 + - text: "world" + start_ms: 1500 + end_ms: 3000 +` + lyrics, err := ParseLyricsfile(input) + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics).To(HaveLen(1)) + + l := lyrics[0] + Expect(l.Synced).To(BeTrue()) + Expect(l.Agents).To(BeNil()) + Expect(l.Line).To(HaveLen(1)) + + line := l.Line[0] + Expect(*line.Start).To(Equal(int64(1000))) + Expect(*line.End).To(Equal(int64(3000))) + Expect(line.Value).To(Equal("Hello world")) + Expect(line.Cue).To(HaveLen(2)) + + Expect(*line.Cue[0].Start).To(Equal(int64(1000))) + Expect(*line.Cue[0].End).To(Equal(int64(1500))) + Expect(line.Cue[0].Value).To(Equal("Hello ")) + Expect(line.Cue[0].ByteStart).To(Equal(0)) + Expect(line.Cue[0].ByteEnd).To(Equal(5)) + Expect(line.Cue[0].AgentID).To(Equal("")) + + Expect(*line.Cue[1].Start).To(Equal(int64(1500))) + Expect(*line.Cue[1].End).To(Equal(int64(3000))) + Expect(line.Cue[1].Value).To(Equal("world")) + Expect(line.Cue[1].ByteStart).To(Equal(6)) + Expect(line.Cue[1].ByteEnd).To(Equal(10)) + Expect(line.Cue[1].AgentID).To(Equal("")) + }) + + It("synthesises voice agents for overlapping lines and attributes per-cue", func() { + input := `version: '1.0' +metadata: + title: 'Duet' +lines: + - text: "Lead vocal" + start_ms: 1000 + end_ms: 4000 + words: + - text: "Lead " + start_ms: 1000 + end_ms: 2000 + - text: "vocal" + start_ms: 2000 + end_ms: 4000 + - text: "echo" + start_ms: 2000 + end_ms: 3000 + words: + - text: "echo" + start_ms: 2000 + end_ms: 3000 +` + lyrics, err := ParseLyricsfile(input) + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics).To(HaveLen(1)) + + l := lyrics[0] + Expect(l.Agents).To(Equal([]Agent{ + {ID: "voice-0", Role: "main"}, + {ID: "voice-1", Role: "voice"}, + })) + Expect(l.Line).To(HaveLen(2)) + + Expect(l.Line[0].Value).To(Equal("Lead vocal")) + Expect(*l.Line[0].Start).To(Equal(int64(1000))) + Expect(*l.Line[0].End).To(Equal(int64(4000))) + Expect(l.Line[0].Cue).To(HaveLen(2)) + Expect(l.Line[0].Cue[0].AgentID).To(Equal("voice-0")) + Expect(l.Line[0].Cue[1].AgentID).To(Equal("voice-0")) + Expect(l.Line[0].Cue[0].ByteStart).To(Equal(0)) + Expect(l.Line[0].Cue[0].ByteEnd).To(Equal(4)) + Expect(l.Line[0].Cue[1].ByteStart).To(Equal(5)) + Expect(l.Line[0].Cue[1].ByteEnd).To(Equal(9)) + + Expect(l.Line[1].Value).To(Equal("echo")) + Expect(*l.Line[1].Start).To(Equal(int64(2000))) + Expect(*l.Line[1].End).To(Equal(int64(3000))) + Expect(l.Line[1].Cue).To(HaveLen(1)) + Expect(l.Line[1].Cue[0].AgentID).To(Equal("voice-1")) + Expect(l.Line[1].Cue[0].ByteStart).To(Equal(0)) + Expect(l.Line[1].Cue[0].ByteEnd).To(Equal(3)) + }) + + It("emits empty lines with Synced=false for instrumental tracks", func() { + input := `version: '1.0' +metadata: + title: 'Solo Piano' + artist: 'Composer' + language: 'eng' + instrumental: true +` + lyrics, err := ParseLyricsfile(input) + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics).To(HaveLen(1)) + + l := lyrics[0] + Expect(l.Kind).To(Equal("main")) + Expect(l.Lang).To(Equal("eng")) + Expect(l.DisplayArtist).To(Equal("Composer")) + Expect(l.DisplayTitle).To(Equal("Solo Piano")) + Expect(l.Synced).To(BeFalse()) + Expect(l.Line).To(BeEmpty()) + Expect(l.Agents).To(BeNil()) + }) + + It("strips agent attribution when overlapping lines carry no cues", func() { + input := `version: '1.0' +lines: + - text: "Lead" + start_ms: 1000 + end_ms: 4000 + - text: "echo" + start_ms: 2000 + end_ms: 3000 +` + lyrics, err := ParseLyricsfile(input) + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics).To(HaveLen(1)) + + l := lyrics[0] + Expect(l.Line).To(HaveLen(2)) + Expect(l.Agents).To(BeNil()) + Expect(l.Line[0].Cue).To(BeNil()) + Expect(l.Line[1].Cue).To(BeNil()) + }) +}) diff --git a/tests/fixtures/test-instrumental.yaml b/tests/fixtures/test-instrumental.yaml new file mode 100644 index 000000000..84190a3b0 --- /dev/null +++ b/tests/fixtures/test-instrumental.yaml @@ -0,0 +1,6 @@ +version: '1.0' +metadata: + title: 'Solo Piano' + artist: 'Composer' + language: 'eng' + instrumental: true diff --git a/tests/fixtures/test-overlapping.yaml b/tests/fixtures/test-overlapping.yaml new file mode 100644 index 000000000..c1f95a87b --- /dev/null +++ b/tests/fixtures/test-overlapping.yaml @@ -0,0 +1,24 @@ +version: '1.0' +metadata: + title: 'Duet' + artist: 'Lead and Echo' + language: 'eng' + +lines: + - text: "Lead vocal" + start_ms: 1000 + end_ms: 4000 + words: + - text: "Lead " + start_ms: 1000 + end_ms: 2000 + - text: "vocal" + start_ms: 2000 + end_ms: 4000 + - text: "echo" + start_ms: 2000 + end_ms: 3000 + words: + - text: "echo" + start_ms: 2000 + end_ms: 3000 diff --git a/tests/fixtures/test-words.yaml b/tests/fixtures/test-words.yaml new file mode 100644 index 000000000..625098d6a --- /dev/null +++ b/tests/fixtures/test-words.yaml @@ -0,0 +1,17 @@ +version: '1.0' +metadata: + title: 'Karaoke Test' + artist: 'Test Artist' + language: 'eng' + +lines: + - text: "Hello world" + start_ms: 1000 + end_ms: 3000 + words: + - text: "Hello " + start_ms: 1000 + end_ms: 1500 + - text: "world" + start_ms: 1500 + end_ms: 3000 diff --git a/tests/fixtures/test.yaml b/tests/fixtures/test.yaml new file mode 100644 index 000000000..bc5022b75 --- /dev/null +++ b/tests/fixtures/test.yaml @@ -0,0 +1,12 @@ +version: '1.0' +metadata: + title: 'Sample Track' + artist: 'Test Artist' + language: 'eng' + offset_ms: -100 + +lines: + - text: "We're no strangers to love" + start_ms: 18800 + - text: "You know the rules and so do I" + start_ms: 22801