mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +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("artistartpriority", "artist.*, album/artist.*, external")
|
||||||
viper.SetDefault("artistimagefolder", "")
|
viper.SetDefault("artistimagefolder", "")
|
||||||
viper.SetDefault("discartpriority", "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded")
|
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("enablegravatar", false)
|
||||||
viper.SetDefault("enablefavourites", true)
|
viper.SetDefault("enablefavourites", true)
|
||||||
viper.SetDefault("enablestarrating", 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)
|
log.Error(ctx, "error parsing srt external file", "path", externalLyric, err)
|
||||||
return nil, 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:
|
default:
|
||||||
lyrics, err := model.ToLyrics("xxx", string(contents))
|
lyrics, err := model.ToLyrics("xxx", string(contents))
|
||||||
if err != nil {
|
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].Start).To(Equal(ptr(int64(22801))))
|
||||||
Expect(lyrics[0].Line[1].Value).To(Equal("UTF16 line two"))
|
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 {
|
if validLine {
|
||||||
|
value, baseCues := parseEnhancedLine(priorLine)
|
||||||
for idx := range timestamps {
|
for idx := range timestamps {
|
||||||
value, cues := parseEnhancedLine(priorLine)
|
startCopy := timestamps[idx]
|
||||||
structuredLines = append(structuredLines, Line{
|
structuredLines = append(structuredLines, Line{
|
||||||
Start: ×tamps[idx],
|
Start: &startCopy,
|
||||||
Value: value,
|
Value: value,
|
||||||
Cue: cues,
|
Cue: shiftELRCCues(baseCues, timestamps[idx]-timestamps[0]),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
timestamps = nil
|
timestamps = nil
|
||||||
@ -179,12 +180,13 @@ func ToLyrics(language, text string) (*Lyrics, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if validLine {
|
if validLine {
|
||||||
|
value, baseCues := parseEnhancedLine(priorLine)
|
||||||
for idx := range timestamps {
|
for idx := range timestamps {
|
||||||
value, cues := parseEnhancedLine(priorLine)
|
startCopy := timestamps[idx]
|
||||||
structuredLines = append(structuredLines, Line{
|
structuredLines = append(structuredLines, Line{
|
||||||
Start: ×tamps[idx],
|
Start: &startCopy,
|
||||||
Value: value,
|
Value: value,
|
||||||
Cue: cues,
|
Cue: shiftELRCCues(baseCues, timestamps[idx]-timestamps[0]),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -313,6 +315,31 @@ func stripEnhancedMarkers(text string) string {
|
|||||||
return enhancedLRCRegex.ReplaceAllString(text, "")
|
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) {
|
func parseTime(line string, match []int) (int64, error) {
|
||||||
var hours, millis int64
|
var hours, millis int64
|
||||||
var err error
|
var err error
|
||||||
|
|||||||
@ -198,6 +198,35 @@ var _ = Describe("ToLyrics", func() {
|
|||||||
{Start: &t1600, Value: "tonight", ByteStart: 11, ByteEnd: 17},
|
{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() {
|
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