feat: add lyricsfile support

This commit is contained in:
Rufei Zhou 2026-06-01 00:48:53 -07:00
parent 81c3597018
commit 59ece1d701
11 changed files with 673 additions and 7 deletions

View File

@ -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)

View File

@ -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 {

View File

@ -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())
})
})
})

View File

@ -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: &timestamps[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: &timestamps[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

View File

@ -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
View 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
View 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
View 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
View 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
View 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
View 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