mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
feat: add lyricsfile support
This commit is contained in:
parent
81c3597018
commit
59ece1d701
@ -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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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() {
|
||||
|
||||
247
model/lyricsfile.go
Normal file
247
model/lyricsfile.go
Normal file
@ -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
|
||||
}
|
||||
205
model/lyricsfile_test.go
Normal file
205
model/lyricsfile_test.go
Normal file
@ -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())
|
||||
})
|
||||
})
|
||||
6
tests/fixtures/test-instrumental.yaml
vendored
Normal file
6
tests/fixtures/test-instrumental.yaml
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
version: '1.0'
|
||||
metadata:
|
||||
title: 'Solo Piano'
|
||||
artist: 'Composer'
|
||||
language: 'eng'
|
||||
instrumental: true
|
||||
24
tests/fixtures/test-overlapping.yaml
vendored
Normal file
24
tests/fixtures/test-overlapping.yaml
vendored
Normal file
@ -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
|
||||
17
tests/fixtures/test-words.yaml
vendored
Normal file
17
tests/fixtures/test-words.yaml
vendored
Normal file
@ -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
|
||||
12
tests/fixtures/test.yaml
vendored
Normal file
12
tests/fixtures/test.yaml
vendored
Normal file
@ -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
|
||||
Loading…
x
Reference in New Issue
Block a user