fix(lyrics): canonicalize structured timing

- preserve source order, exact ends, pauses, and overlapping voices
- validate cue timing and UTF-8 geometry without mutating source data

Signed-off-by: ranokay <github@ranokay.com>
This commit is contained in:
ranokay 2026-07-17 10:57:36 +03:00
parent 190a097e2e
commit cd67647cd9
No known key found for this signature in database
11 changed files with 871 additions and 289 deletions

View File

@ -59,19 +59,16 @@ var _ = Describe("Lyrics", func() {
Line: []model.Line{ Line: []model.Line{
{ {
Start: new(int64(1000)), Start: new(int64(1000)),
End: new(int64(3000)),
Value: "Lead words", Value: "Lead words",
Cue: []model.Cue{ Cue: []model.Cue{
{ {
Start: new(int64(1000)), Start: new(int64(1000)),
End: new(int64(1500)),
Value: "Lead ", Value: "Lead ",
ByteStart: 0, ByteStart: 0,
ByteEnd: 4, ByteEnd: 4,
}, },
{ {
Start: new(int64(1500)), Start: new(int64(1500)),
End: new(int64(3000)),
Value: "words", Value: "words",
ByteStart: 5, ByteStart: 5,
ByteEnd: 9, ByteEnd: 9,
@ -255,6 +252,25 @@ var _ = Describe("Lyrics", func() {
})) }))
}) })
It("skips a malformed claimed sidecar and resolves the next source", func() {
dir, err := os.MkdirTemp("", "lyrics-malformed-fallback-*")
Expect(err).ToNot(HaveOccurred())
DeferCleanup(func() {
Expect(os.RemoveAll(dir)).To(Succeed())
})
Expect(os.WriteFile(filepath.Join(dir, "song.ttml"), []byte(`<tt><body><p>broken`), 0600)).To(Succeed())
Expect(os.WriteFile(filepath.Join(dir, "song.lrc"), []byte("[00:01.00]Fallback line"), 0600)).To(Succeed())
conf.Server.LyricsPriority = ".ttml,.lrc"
svc := lyrics.NewLyrics(nil, nil)
list, err := svc.GetLyrics(ctx, &model.MediaFile{LibraryPath: dir, Path: "song.mp3"})
Expect(err).ToNot(HaveOccurred())
Expect(list).To(HaveLen(1))
Expect(list[0].Line).To(Equal([]model.Line{{Start: new(int64(1000)), Value: "Fallback line"}}))
})
Context("Errors", func() { Context("Errors", func() {
var RegularUserContext = XContext var RegularUserContext = XContext
var isRegularUser = os.Getuid() != 0 var isRegularUser = os.Getuid() != 0

View File

@ -92,13 +92,15 @@ func parseLRC(language, text string) (*Lyrics, error) {
} }
if validLine { if validLine {
value, baseCues := parseEnhancedLine(priorLine) value, baseCues, baseEnd := parseEnhancedLine(priorLine)
for idx := range timestamps { for idx := range timestamps {
startCopy := timestamps[idx] startCopy := timestamps[idx]
shift := timestamps[idx] - timestamps[0]
structuredLines = append(structuredLines, Line{ structuredLines = append(structuredLines, Line{
Start: &startCopy, Start: &startCopy,
End: shiftELRCTime(baseEnd, shift),
Value: value, Value: value,
Cue: shiftELRCCues(baseCues, timestamps[idx]-timestamps[0]), Cue: shiftELRCCues(baseCues, shift),
}) })
} }
timestamps = nil timestamps = nil
@ -143,13 +145,15 @@ func parseLRC(language, text string) (*Lyrics, error) {
} }
if validLine { if validLine {
value, baseCues := parseEnhancedLine(priorLine) value, baseCues, baseEnd := parseEnhancedLine(priorLine)
for idx := range timestamps { for idx := range timestamps {
startCopy := timestamps[idx] startCopy := timestamps[idx]
shift := timestamps[idx] - timestamps[0]
structuredLines = append(structuredLines, Line{ structuredLines = append(structuredLines, Line{
Start: &startCopy, Start: &startCopy,
End: shiftELRCTime(baseEnd, shift),
Value: value, Value: value,
Cue: shiftELRCCues(baseCues, timestamps[idx]-timestamps[0]), Cue: shiftELRCCues(baseCues, shift),
}) })
} }
} }
@ -162,23 +166,23 @@ func parseLRC(language, text string) (*Lyrics, error) {
}) })
} }
lyrics := Lyrics{ lyrics := NormalizeLyrics(Lyrics{
DisplayArtist: artist, DisplayArtist: artist,
DisplayTitle: title, DisplayTitle: title,
Lang: language, Lang: language,
Line: normalizeCueLines(structuredLines), Line: structuredLines,
Offset: offset, Offset: offset,
Synced: synced, Synced: synced,
} })
return &lyrics, nil return &lyrics, nil
} }
// parseEnhancedLine extracts word-level timing cues from Enhanced LRC inline markers // parseEnhancedLine extracts word-level timing cues from Enhanced LRC inline markers
// and computes UTF-8 byte offsets against the final stripped line value. // and computes UTF-8 byte offsets against the final stripped line value.
func parseEnhancedLine(text string) (string, []Cue) { func parseEnhancedLine(text string) (string, []Cue, *int64) {
matches := enhancedLRCRegex.FindAllStringSubmatchIndex(text, -1) matches := enhancedLRCRegex.FindAllStringSubmatchIndex(text, -1)
if len(matches) == 0 { if len(matches) == 0 {
return strings.TrimSpace(text), nil return strings.TrimSpace(text), nil, nil
} }
type segment struct { type segment struct {
@ -189,6 +193,9 @@ func parseEnhancedLine(text string) (string, []Cue) {
segments := make([]segment, 0, len(matches)) segments := make([]segment, 0, len(matches))
var rawValue strings.Builder var rawValue strings.Builder
// Enhanced LRC permits text before the first inline timestamp. It is part of
// the visible line even though it has no word-level timing of its own.
rawValue.WriteString(text[:matches[0][0]])
var trailingEnd *int64 var trailingEnd *int64
for i, match := range matches { for i, match := range matches {
timeMs, err := parseTime( timeMs, err := parseTime(
@ -234,7 +241,7 @@ func parseEnhancedLine(text string) (string, []Cue) {
} }
if len(segments) == 0 { if len(segments) == 0 {
return strings.TrimSpace(stripEnhancedMarkers(text)), nil return strings.TrimSpace(stripEnhancedMarkers(text)), nil, trailingEnd
} }
finalRaw := rawValue.String() finalRaw := rawValue.String()
@ -266,7 +273,7 @@ func parseEnhancedLine(text string) (string, []Cue) {
cues[len(cues)-1].End = trailingEnd cues[len(cues)-1].End = trailingEnd
} }
return strings.TrimSpace(finalRaw), cues return strings.TrimSpace(finalRaw), cues, trailingEnd
} }
// adjustGroup remaps a capture group index from the original match to our rewritten "[...]" string. // adjustGroup remaps a capture group index from the original match to our rewritten "[...]" string.
@ -286,6 +293,14 @@ func stripEnhancedMarkers(text string) string {
return enhancedLRCRegex.ReplaceAllString(text, "") return enhancedLRCRegex.ReplaceAllString(text, "")
} }
func shiftELRCTime(value *int64, offsetMs int64) *int64 {
if value == nil {
return nil
}
shifted := *value + offsetMs
return &shifted
}
// shiftELRCCues returns a deep copy of baseCues with each cue's Start/End // 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 shifted by offsetMs. Inline ELRC word markers parse to absolute
// timestamps anchored at the line's first occurrence, so repeated-line LRC // timestamps anchored at the line's first occurrence, so repeated-line LRC

View File

@ -118,17 +118,17 @@ var _ = Describe("parseLRC", func() {
line0 := lyrics.Line[0] line0 := lyrics.Line[0]
Expect(line0.Start).To(Equal(&t1000)) Expect(line0.Start).To(Equal(&t1000))
Expect(line0.End).To(Equal(&t3000)) Expect(line0.End).To(BeNil())
Expect(line0.Value).To(Equal("Some lyrics here")) Expect(line0.Value).To(Equal("Some lyrics here"))
Expect(line0.Cue).To(Equal([]Cue{ Expect(line0.Cue).To(Equal([]Cue{
{Start: &t1000, End: &t1500, Value: "Some ", ByteStart: 0, ByteEnd: 4}, {Start: &t1000, Value: "Some ", ByteStart: 0, ByteEnd: 4},
{Start: &t1500, End: &t2000, Value: "lyrics ", ByteStart: 5, ByteEnd: 11}, {Start: &t1500, Value: "lyrics ", ByteStart: 5, ByteEnd: 11},
{Start: &t2000, End: &t3000, Value: "here", ByteStart: 12, ByteEnd: 15}, {Start: &t2000, Value: "here", ByteStart: 12, ByteEnd: 15},
})) }))
line1 := lyrics.Line[1] line1 := lyrics.Line[1]
Expect(line1.Start).To(Equal(&t3000)) Expect(line1.Start).To(Equal(&t3000))
Expect(line1.End).To(Equal(&t3500)) Expect(line1.End).To(BeNil())
Expect(line1.Value).To(Equal("More words")) Expect(line1.Value).To(Equal("More words"))
Expect(line1.Cue).To(Equal([]Cue{ Expect(line1.Cue).To(Equal([]Cue{
{Start: &t3000, Value: "More ", ByteStart: 0, ByteEnd: 4}, {Start: &t3000, Value: "More ", ByteStart: 0, ByteEnd: 4},
@ -153,14 +153,12 @@ var _ = Describe("parseLRC", func() {
Expect(lyrics.Line).To(HaveLen(3)) Expect(lyrics.Line).To(HaveLen(3))
t1000, t1500, t5000, t5500 := int64(1000), int64(1500), int64(5000), int64(5500) t1000, t1500, t5000, t5500 := int64(1000), int64(1500), int64(5000), int64(5500)
t3000 := int64(3000)
Expect(lyrics.Line[0].Cue).To(Equal([]Cue{ Expect(lyrics.Line[0].Cue).To(Equal([]Cue{
{Start: &t1000, End: &t1500, Value: "Some ", ByteStart: 0, ByteEnd: 4}, {Start: &t1000, Value: "Some ", ByteStart: 0, ByteEnd: 4},
{Start: &t1500, End: &t3000, Value: "lyrics", ByteStart: 5, ByteEnd: 10}, {Start: &t1500, Value: "lyrics", ByteStart: 5, ByteEnd: 10},
})) }))
Expect(lyrics.Line[0].Value).To(Equal("Some lyrics")) Expect(lyrics.Line[0].Value).To(Equal("Some lyrics"))
Expect(lyrics.Line[0].End).To(Equal(&t3000)) Expect(lyrics.Line[0].End).To(BeNil())
Expect(lyrics.Line[1].Cue).To(BeNil()) Expect(lyrics.Line[1].Cue).To(BeNil())
Expect(lyrics.Line[1].Value).To(Equal("Plain line")) Expect(lyrics.Line[1].Value).To(Equal("Plain line"))
@ -188,6 +186,34 @@ var _ = Describe("parseLRC", func() {
})) }))
}) })
It("preserves text before the first Enhanced LRC marker", func() {
lyrics, err := parseLRC("xxx", "[00:01.00]Prelude <00:01.50>timed")
Expect(err).ToNot(HaveOccurred())
Expect(lyrics.Line).To(HaveLen(1))
Expect(lyrics.Line[0].Value).To(Equal("Prelude timed"))
Expect(lyrics.Line[0].Cue).To(Equal([]Cue{{
Start: new(int64(1500)), Value: "timed", ByteStart: 8, ByteEnd: 12,
}}))
})
It("preserves timestamp-only lines as explicit pauses", func() {
lyrics, err := parseLRC("xxx", "[00:01.00]Before\n[00:02.00]\n[00:03.00]After")
Expect(err).ToNot(HaveOccurred())
Expect(lyrics.Line).To(Equal([]Line{
{Start: new(int64(1000)), Value: "Before"},
{Start: new(int64(2000)), Value: ""},
{Start: new(int64(3000)), Value: "After"},
}))
})
It("preserves a trailing end on prefix-only Enhanced LRC lines", func() {
lyrics, err := parseLRC("xxx", "[00:01.00]Prelude<00:02.00>")
Expect(err).ToNot(HaveOccurred())
Expect(lyrics.Line).To(Equal([]Line{{
Start: new(int64(1000)), End: new(int64(2000)), Value: "Prelude",
}}))
})
It("should use a trailing Enhanced LRC marker as the end of the last word", func() { It("should use a trailing Enhanced LRC marker as the end of the last word", func() {
lyrics, err := parseLRC("xxx", "[00:01.00]<00:01.00>Some <00:01.50>lyrics<00:02.00>\n[00:30.00]Instrumental over") lyrics, err := parseLRC("xxx", "[00:01.00]<00:01.00>Some <00:01.50>lyrics<00:02.00>\n[00:30.00]Instrumental over")
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
@ -203,6 +229,15 @@ var _ = Describe("parseLRC", func() {
})) }))
}) })
It("preserves an explicit trailing marker equal to the next line start", func() {
lyrics, err := parseLRC("xxx", "[00:01.00]<00:01.00>Some lyrics<00:02.00>\n[00:02.00]Next line")
Expect(err).ToNot(HaveOccurred())
Expect(lyrics.Line).To(HaveLen(2))
Expect(lyrics.Line[0].End).To(Equal(new(int64(2000))))
Expect(lyrics.Line[0].Cue).To(HaveLen(1))
Expect(lyrics.Line[0].Cue[0].End).To(Equal(new(int64(2000))))
})
It("should shift a trailing Enhanced LRC marker for repeated line occurrences", func() { It("should shift a trailing Enhanced LRC marker for repeated line occurrences", func() {
lyrics, err := parseLRC("xxx", "[00:10.00][00:30.00]<00:10.10>Hello <00:10.50>world<00:10.90>") lyrics, err := parseLRC("xxx", "[00:10.00][00:30.00]<00:10.10>Hello <00:10.50>world<00:10.90>")
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
@ -237,15 +272,15 @@ var _ = Describe("parseLRC", func() {
t30500 := int64(30500) t30500 := int64(30500)
Expect(lyrics.Line[0].Start).To(Equal(&t10000)) Expect(lyrics.Line[0].Start).To(Equal(&t10000))
Expect(lyrics.Line[0].End).To(Equal(&t30000)) Expect(lyrics.Line[0].End).To(BeNil())
Expect(lyrics.Line[0].Value).To(Equal("Hello world")) Expect(lyrics.Line[0].Value).To(Equal("Hello world"))
Expect(lyrics.Line[0].Cue).To(Equal([]Cue{ Expect(lyrics.Line[0].Cue).To(Equal([]Cue{
{Start: &t10100, End: &t10500, Value: "Hello ", ByteStart: 0, ByteEnd: 5}, {Start: &t10100, Value: "Hello ", ByteStart: 0, ByteEnd: 5},
{Start: &t10500, End: &t30000, Value: "world", ByteStart: 6, ByteEnd: 10}, {Start: &t10500, Value: "world", ByteStart: 6, ByteEnd: 10},
})) }))
Expect(lyrics.Line[1].Start).To(Equal(&t30000)) Expect(lyrics.Line[1].Start).To(Equal(&t30000))
Expect(lyrics.Line[1].End).To(Equal(&t30500)) Expect(lyrics.Line[1].End).To(BeNil())
Expect(lyrics.Line[1].Value).To(Equal("Hello world")) Expect(lyrics.Line[1].Value).To(Equal("Hello world"))
Expect(lyrics.Line[1].Cue).To(Equal([]Cue{ Expect(lyrics.Line[1].Cue).To(Equal([]Cue{
{Start: &t30100, Value: "Hello ", ByteStart: 0, ByteEnd: 5}, {Start: &t30100, Value: "Hello ", ByteStart: 0, ByteEnd: 5},

View File

@ -3,6 +3,7 @@ package model
import ( import (
"bytes" "bytes"
"fmt" "fmt"
"sort"
"strings" "strings"
"github.com/navidrome/navidrome/utils/str" "github.com/navidrome/navidrome/utils/str"
@ -50,7 +51,7 @@ func parseLyricsfile(lang string, contents []byte) (LyricList, error) {
} }
if doc.Metadata.Instrumental { if doc.Metadata.Instrumental {
return LyricList{normalizeLyrics(lyrics)}, nil return LyricList{NormalizeLyrics(lyrics)}, nil
} }
if len(doc.Lines) == 0 { if len(doc.Lines) == 0 {
@ -59,14 +60,21 @@ func parseLyricsfile(lang string, contents []byte) (LyricList, error) {
return nil, nil return nil, nil
} }
lyrics.Line = lines lyrics.Line = lines
return LyricList{normalizeLyrics(lyrics)}, nil return LyricList{NormalizeLyrics(lyrics)}, nil
} }
if err := validateLyricsfileLineStarts(doc.Lines); err != nil {
if lines := buildPlainLyricsfileLines(doc.Plain); len(lines) > 0 {
lyrics.Line = lines
return LyricList{NormalizeLyrics(lyrics)}, nil
}
return nil, err
}
lines, agents := buildLyricsfileLines(doc.Lines) lines, agents := buildLyricsfileLines(doc.Lines)
lyrics.Line = lines lyrics.Line = lines
lyrics.Agents = agents lyrics.Agents = agents
lyrics.Synced = true lyrics.Synced = true
return LyricList{normalizeLyrics(lyrics)}, nil return LyricList{NormalizeLyrics(lyrics)}, nil
} }
const lyricsfileVersion = "1.0" const lyricsfileVersion = "1.0"
@ -100,17 +108,26 @@ type lyricsfileMetadata struct {
type lyricsfileLineEntry struct { type lyricsfileLineEntry struct {
Text string `yaml:"text"` Text string `yaml:"text"`
StartMs int64 `yaml:"start_ms"` StartMs *int64 `yaml:"start_ms"`
EndMs *int64 `yaml:"end_ms"` EndMs *int64 `yaml:"end_ms"`
Words []lyricsfileWordEntry `yaml:"words"` Words []lyricsfileWordEntry `yaml:"words"`
} }
type lyricsfileWordEntry struct { type lyricsfileWordEntry struct {
Text string `yaml:"text"` Text string `yaml:"text"`
StartMs int64 `yaml:"start_ms"` StartMs *int64 `yaml:"start_ms"`
EndMs *int64 `yaml:"end_ms"` EndMs *int64 `yaml:"end_ms"`
} }
func validateLyricsfileLineStarts(entries []lyricsfileLineEntry) error {
for i, entry := range entries {
if entry.StartMs == nil || *entry.StartMs < 0 {
return fmt.Errorf("Lyricsfile line %d has a missing or invalid start_ms", i+1)
}
}
return nil
}
// buildLyricsfileLines converts YAML line entries to model.Line entries with // buildLyricsfileLines converts YAML line entries to model.Line entries with
// per-cue AgentIDs assigned by streaming overlap clustering (lowest-free // per-cue AgentIDs assigned by streaming overlap clustering (lowest-free
// voice ID). The Agents slice is emitted only when at least one cue carries // voice ID). The Agents slice is emitted only when at least one cue carries
@ -122,28 +139,30 @@ func buildLyricsfileLines(entries []lyricsfileLineEntry) ([]Line, []Agent) {
return nil, nil return nil, nil
} }
// Resolved end timestamps per entry: explicit end_ms, final word end_ms, // Only explicit line ends and trustworthy final-word ends are exact. The
// then the next entry's start. The last entry's end stays nil when no // next line's start remains a cue fallback and never becomes Line.End.
// explicit or word-level end is available.
ends := make([]*int64, len(entries)) ends := make([]*int64, len(entries))
for i := range entries { for i := range entries {
var nextStart *int64 ends[i] = lyricsfileLineEnd(entries[i])
if i+1 < len(entries) {
v := entries[i+1].StartMs
nextStart = &v
}
ends[i] = lyricsfileLineEnd(entries[i], nextStart)
} }
// Allocate overlap voices chronologically, then write them back in source
// display order. Out-of-order documents therefore keep their intended layout.
order := make([]int, len(entries))
for i := range order {
order[i] = i
}
sort.SliceStable(order, func(i, j int) bool {
return *entries[order[i]].StartMs < *entries[order[j]].StartMs
})
active := map[int]int64{} active := map[int]int64{}
maxVoice := -1 maxVoice := -1
anyCues := false voiceByEntry := make([]int, len(entries))
lines := make([]Line, 0, len(entries)) for _, entryIndex := range order {
start := *entries[entryIndex].StartMs
for i, entry := range entries { for voiceID, voiceEnd := range active {
for vID, vEnd := range active { if voiceEnd <= start {
if vEnd <= entry.StartMs { delete(active, voiceID)
delete(active, vID)
} }
} }
@ -154,17 +173,28 @@ func buildLyricsfileLines(entries []lyricsfileLineEntry) ([]Line, []Agent) {
} }
voiceID++ voiceID++
} }
if voiceID > maxVoice { voiceByEntry[entryIndex] = voiceID
maxVoice = voiceID maxVoice = max(maxVoice, voiceID)
}
end := start
if ends[entryIndex] != nil && *ends[entryIndex] >= start {
end = *ends[entryIndex]
}
active[voiceID] = end
}
anyCues := false
lines := make([]Line, 0, len(entries))
for i, entry := range entries {
voiceID := voiceByEntry[i]
agentID := fmt.Sprintf("voice-%d", voiceID) agentID := fmt.Sprintf("voice-%d", voiceID)
cues, value := wordsToLineCues(entry, agentID) cues, value := wordsToLineCues(entry, agentID)
if len(cues) > 0 { if len(cues) > 0 {
anyCues = true anyCues = true
} }
startMs := entry.StartMs startMs := *entry.StartMs
line := Line{ line := Line{
Start: &startMs, Start: &startMs,
End: ends[i], End: ends[i],
@ -172,14 +202,6 @@ func buildLyricsfileLines(entries []lyricsfileLineEntry) ([]Line, []Agent) {
Cue: cues, Cue: cues,
} }
lines = append(lines, line) 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 // Monophonic source, or attribution that has nowhere to land: emit no
@ -207,25 +229,32 @@ func buildLyricsfileLines(entries []lyricsfileLineEntry) ([]Line, []Agent) {
return lines, agents return lines, agents
} }
func lyricsfileLineEnd(entry lyricsfileLineEntry, nextStart *int64) *int64 { func lyricsfileLineEnd(entry lyricsfileLineEntry) *int64 {
if entry.EndMs != nil { if entry.EndMs != nil {
v := *entry.EndMs v := *entry.EndMs
return &v return &v
} }
if len(entry.Words) > 0 { if lyricsfileWordTimingsValid(entry.Words) && len(entry.Words) > 0 {
lastWord := entry.Words[len(entry.Words)-1] lastWord := entry.Words[len(entry.Words)-1]
if lastWord.EndMs != nil { if lastWord.EndMs != nil {
v := *lastWord.EndMs v := *lastWord.EndMs
return &v return &v
} }
} }
if nextStart != nil {
v := *nextStart
return &v
}
return nil return nil
} }
func lyricsfileWordTimingsValid(words []lyricsfileWordEntry) bool {
var previous int64
for i, word := range words {
if word.StartMs == nil || *word.StartMs < 0 || (i > 0 && *word.StartMs < previous) {
return false
}
previous = *word.StartMs
}
return true
}
func buildPlainLyricsfileLines(plain string) []Line { func buildPlainLyricsfileLines(plain string) []Line {
plain = str.SanitizeText(plain) plain = str.SanitizeText(plain)
rawLines := strings.Split(plain, "\n") rawLines := strings.Split(plain, "\n")
@ -251,16 +280,25 @@ func wordsToLineCues(entry lyricsfileLineEntry, agentID string) ([]Cue, string)
return nil, str.SanitizeText(entry.Text) return nil, str.SanitizeText(entry.Text)
} }
values := make([]string, len(entry.Words))
var sb strings.Builder var sb strings.Builder
for _, w := range entry.Words { for i, word := range entry.Words {
sb.WriteString(w.Text) values[i] = str.SanitizeText(word.Text)
sb.WriteString(values[i])
} }
lineValue := sb.String() lineValue := sb.String()
if !lyricsfileWordTimingsValid(entry.Words) {
return nil, lineValue
}
cues := make([]Cue, len(entry.Words)) cues := make([]Cue, len(entry.Words))
cursor := 0 cursor := 0
for i, w := range entry.Words { for i, w := range entry.Words {
valueBytes := len(w.Text) value := values[i]
if value == "" {
return nil, lineValue
}
valueBytes := len(value)
bs := cursor bs := cursor
be := bs be := bs
if valueBytes > 0 { if valueBytes > 0 {
@ -268,10 +306,10 @@ func wordsToLineCues(entry lyricsfileLineEntry, agentID string) ([]Cue, string)
cursor = be + 1 cursor = be + 1
} }
s := w.StartMs s := *w.StartMs
cue := Cue{ cue := Cue{
Start: &s, Start: &s,
Value: w.Text, Value: value,
ByteStart: bs, ByteStart: bs,
ByteEnd: be, ByteEnd: be,
AgentID: agentID, AgentID: agentID,

View File

@ -55,8 +55,7 @@ lines:
Expect(l.Line).To(HaveLen(2)) Expect(l.Line).To(HaveLen(2))
Expect(*l.Line[0].Start).To(Equal(int64(18800))) Expect(*l.Line[0].Start).To(Equal(int64(18800)))
Expect(l.Line[0].End).ToNot(BeNil()) Expect(l.Line[0].End).To(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].Value).To(Equal("We're no strangers to love"))
Expect(l.Line[0].Cue).To(BeNil()) Expect(l.Line[0].Cue).To(BeNil())
@ -297,4 +296,103 @@ lines:
Expect(l.Line[0].Cue).To(BeNil()) Expect(l.Line[0].Cue).To(BeNil())
Expect(l.Line[1].Cue).To(BeNil()) Expect(l.Line[1].Cue).To(BeNil())
}) })
It("distinguishes an omitted line start from an explicit zero", func() {
explicitZero := `version: "1.0"
lines:
- text: "At zero"
start_ms: 0
`
lyrics, err := parseLyricsfile("", []byte(explicitZero))
Expect(err).ToNot(HaveOccurred())
Expect(lyrics[0].Line[0].Start).To(Equal(new(int64(0))))
missing := `version: "1.0"
lines:
- text: "Missing start"
plain: |
Safe fallback
`
lyrics, err = parseLyricsfile("", []byte(missing))
Expect(err).ToNot(HaveOccurred())
Expect(lyrics[0].Synced).To(BeFalse())
Expect(lyrics[0].Line).To(Equal([]Line{{Value: "Safe fallback"}}))
_, err = parseLyricsfile("", []byte(`version: "1.0"
lines:
- text: "Missing start"
`))
Expect(err).To(MatchError(ContainSubstring("missing or invalid start_ms")))
})
It("sanitizes word text before computing UTF-8 offsets", func() {
input := `version: "1.0"
lines:
- text: "ignored"
start_ms: 0
end_ms: 1000
words:
- text: "<script>unsafe</script>Hi "
start_ms: 0
end_ms: 500
- text: "世"
start_ms: 500
end_ms: 1000
`
lyrics, err := parseLyricsfile("", []byte(input))
Expect(err).ToNot(HaveOccurred())
line := lyrics[0].Line[0]
Expect(line.Value).To(Equal("Hi 世"))
Expect(line.Cue).To(Equal([]Cue{
{Start: new(int64(0)), End: new(int64(500)), Value: "Hi ", ByteStart: 0, ByteEnd: 2},
{Start: new(int64(500)), End: new(int64(1000)), Value: "世", ByteStart: 3, ByteEnd: 5},
}))
})
DescribeTable("retains reconstructed text but drops invalid word timing",
func(words string) {
input := `version: "1.0"
lines:
- text: "ignored"
start_ms: 1000
words:
` + words
lyrics, err := parseLyricsfile("", []byte(input))
Expect(err).ToNot(HaveOccurred())
Expect(lyrics[0].Line[0].Value).To(Equal("hello"))
Expect(lyrics[0].Line[0].Cue).To(BeNil())
},
Entry("missing word start", " - text: \"hello\"\n"),
Entry("out-of-order word starts", " - text: \"hel\"\n start_ms: 1500\n - text: \"lo\"\n start_ms: 1400\n"),
)
It("assigns overlapping voices chronologically while retaining display order", func() {
input := `version: "1.0"
lines:
- text: "Later"
start_ms: 3000
end_ms: 5000
words:
- text: "Later"
start_ms: 3000
end_ms: 5000
- text: "Earlier"
start_ms: 1000
end_ms: 4000
words:
- text: "Earlier"
start_ms: 1000
end_ms: 4000
`
lyrics, err := parseLyricsfile("", []byte(input))
Expect(err).ToNot(HaveOccurred())
Expect(lyrics[0].Line[0].Value).To(Equal("Later"))
Expect(lyrics[0].Line[0].Cue[0].AgentID).To(Equal("voice-1"))
Expect(lyrics[0].Line[1].Value).To(Equal("Earlier"))
Expect(lyrics[0].Line[1].Cue[0].AgentID).To(Equal("voice-0"))
Expect(lyrics[0].Agents).To(Equal([]Agent{
{ID: "voice-0", Role: "main"},
{ID: "voice-1", Role: "voice"},
}))
})
}) })

View File

@ -2,99 +2,158 @@ package model
import ( import (
"slices" "slices"
"strings"
"unicode/utf8"
"github.com/navidrome/navidrome/utils/gg" "github.com/navidrome/navidrome/utils/gg"
) )
func normalizeLyrics(lyrics Lyrics) Lyrics { // NormalizeLyrics returns a canonical, independent copy of lyrics. It keeps
lyrics.Line = normalizeCueLines(lyrics.Line) // source line order and overlapping vocal timelines intact while repairing
if len(lyrics.Agents) == 0 { // timing only when the source contains enough information to do so.
lyrics.Agents = nil func NormalizeLyrics(lyrics Lyrics) Lyrics {
} out := cloneLyrics(lyrics)
return lyrics out.Line = normalizeCueLines(out.Line, out.Agents)
out.Agents = pruneLyricAgents(out.Line, out.Agents)
return out
} }
func normalizeCueLines(lines []Line) []Line { func cloneLyrics(lyrics Lyrics) Lyrics {
out := lyrics
out.Offset = gg.Clone(lyrics.Offset)
out.Agents = slices.Clone(lyrics.Agents)
out.Line = make([]Line, len(lyrics.Line))
for i := range lyrics.Line {
out.Line[i] = cloneLyricLine(lyrics.Line[i])
}
return out
}
func cloneLyricLine(line Line) Line {
out := line
out.Start = gg.Clone(line.Start)
out.End = gg.Clone(line.End)
out.Cue = make([]Cue, len(line.Cue))
for i := range line.Cue {
out.Cue[i] = line.Cue[i]
out.Cue[i].Start = gg.Clone(line.Cue[i].Start)
out.Cue[i].End = gg.Clone(line.Cue[i].End)
}
if len(out.Cue) == 0 {
out.Cue = nil
}
return out
}
func normalizeCueLines(lines []Line, agents []Agent) []Line {
if len(lines) == 0 { if len(lines) == 0 {
return lines return lines
} }
normalized := make([]Line, len(lines)) knownAgents := make(map[string]struct{}, len(agents))
copy(normalized, lines) for _, agent := range agents {
id := strings.TrimSpace(agent.ID)
for i := range normalized { if id != "" {
if len(normalized[i].Cue) > 0 { knownAgents[id] = struct{}{}
normalized[i].Cue = slices.Clone(normalized[i].Cue)
} }
var fallbackEnd *int64
if normalized[i].End != nil {
v := *normalized[i].End
fallbackEnd = &v
} else if i+1 < len(normalized) && normalized[i+1].Start != nil {
v := *normalized[i+1].Start
fallbackEnd = &v
}
normalized[i] = normalizeCueLine(normalized[i], fallbackEnd)
} }
return normalized for i := range lines {
} line := lines[i]
if line.Start != nil && *line.Start < 0 {
line.Start = nil
line.End = nil
}
if line.Start != nil && line.End != nil && *line.End < *line.Start {
line.End = nil
}
func normalizeLineTiming(line Line) Line { if len(line.Cue) == 0 {
if len(line.Cue) == 0 { if line.Start == nil {
return line line.End = nil
} }
lines[i] = line
continue
}
var earliestStart *int64 cues, valid := validateCueGeometry(line.Value, line.Cue, knownAgents)
var latestEnd *int64 if !valid {
for i := range line.Cue { line.Cue = nil
token := line.Cue[i] lines[i] = line
if token.Start != nil { continue
if earliestStart == nil || *token.Start < *earliestStart { }
v := *token.Start
earliestStart = &v var nextTimedStart *int64
for j := i + 1; j < len(lines); j++ {
if lines[j].Start != nil {
nextTimedStart = gg.Clone(lines[j].Start)
break
} }
} }
candidateEnd := token.End line.Cue = normalizeCueEndsByAgent(cues, line.End, nextTimedStart)
if candidateEnd == nil { line = widenLineTiming(line, cues, nextTimedStart)
candidateEnd = token.Start lines[i] = line
}
return lines
}
func validateCueGeometry(lineValue string, cues []Cue, knownAgents map[string]struct{}) ([]Cue, bool) {
if !utf8.ValidString(lineValue) {
return nil, false
}
out := make([]Cue, len(cues))
lastByteEndByAgent := make(map[string]int)
lastStartByAgent := make(map[string]int64)
seenAgent := make(map[string]bool)
for i, cue := range cues {
out[i] = cue
out[i].Start = gg.Clone(cue.Start)
out[i].End = gg.Clone(cue.End)
if cue.Start == nil || *cue.Start < 0 || !utf8.ValidString(cue.Value) {
return nil, false
} }
if candidateEnd != nil { if cue.ByteStart < 0 || cue.ByteEnd < cue.ByteStart || cue.ByteEnd >= len(lineValue) {
if latestEnd == nil || *candidateEnd > *latestEnd { return nil, false
v := *candidateEnd }
latestEnd = &v agentID := strings.TrimSpace(cue.AgentID)
out[i].AgentID = agentID
if agentID != "" {
if _, ok := knownAgents[agentID]; !ok {
return nil, false
} }
} }
} if lastByteEnd, seen := lastByteEndByAgent[agentID]; seen && cue.ByteStart <= lastByteEnd {
return nil, false
}
if !isRuneBoundary(lineValue, cue.ByteStart) || !isRuneBoundary(lineValue, cue.ByteEnd+1) {
return nil, false
}
if lineValue[cue.ByteStart:cue.ByteEnd+1] != cue.Value {
return nil, false
}
if seenAgent[agentID] && *cue.Start < lastStartByAgent[agentID] {
return nil, false
}
seenAgent[agentID] = true
lastStartByAgent[agentID] = *cue.Start
lastByteEndByAgent[agentID] = cue.ByteEnd
if line.Start == nil && earliestStart != nil { if cue.End != nil && *cue.End < *cue.Start {
v := *earliestStart out[i].End = nil
line.Start = &v }
} }
if line.End == nil && latestEnd != nil { return out, true
v := *latestEnd
line.End = &v
}
return line
} }
func normalizeCueLine(line Line, fallbackEnd *int64) Line { func isRuneBoundary(value string, offset int) bool {
if len(line.Cue) == 0 { return offset == 0 || offset == len(value) || utf8.RuneStart(value[offset])
return line
}
line.Cue = normalizeCueEndsByAgent(line.Cue, fallbackEnd)
return normalizeLineTiming(line)
} }
// normalizeCueEndsByAgent resolves cue end times independently per agent so that func normalizeCueEndsByAgent(cues []Cue, lineEnd, nextTimedStart *int64) []Cue {
// background (or other parallel) layers, whose cues interleave with the main
// timeline but are stored together in document order, do not clamp each other's
// ends. Each agent group is normalized in its own document order; results are
// reassembled into the original cue positions.
func normalizeCueEndsByAgent(cues []Cue, fallbackEnd *int64) []Cue {
groups := make(map[string][]int) groups := make(map[string][]int)
order := make([]string, 0, 2) order := make([]string, 0, 2)
for i := range cues { for i := range cues {
@ -105,36 +164,150 @@ func normalizeCueEndsByAgent(cues []Cue, fallbackEnd *int64) []Cue {
groups[id] = append(groups[id], i) groups[id] = append(groups[id], i)
} }
// Single agent: the document order already matches the timeline, so the
// straightforward normalization applies without regrouping.
if len(order) <= 1 {
return NormalizeCueEnds(cues, fallbackEnd)
}
out := slices.Clone(cues) out := slices.Clone(cues)
for _, id := range order { for _, id := range order {
idxs := groups[id] idxs := groups[id]
group := make([]Cue, len(idxs)) group := make([]Cue, len(idxs))
for gi, pos := range idxs { for i, pos := range idxs {
group[gi] = cues[pos] group[i] = cues[pos]
} }
group = NormalizeCueEnds(group, fallbackEnd) group = normalizePartialCueEnds(group, lineEnd, nextTimedStart)
for gi, pos := range idxs { for i, pos := range idxs {
out[pos] = group[gi] out[pos] = group[i]
} }
} }
return out return out
} }
// NormalizeCueEnds resolves missing cue end times within a single ordered cue func normalizePartialCueEnds(cues []Cue, lineEnd, nextTimedStart *int64) []Cue {
// group: each end is filled from the next cue's start, then from fallbackEnd, out := slices.Clone(cues)
// and is clamped so it never precedes the cue's own start nor overruns the next hasEnd := false
// cue. End times are all-or-none — if any cue still lacks an end afterwards, all for _, cue := range out {
// ends in the group are cleared. The input slice is never mutated. if cue.End != nil {
// hasEnd = true
// Exported because the Subsonic enhanced-lyrics serializer resolves cue ends break
// per agent group while building the response; all other normalization is }
// package-internal. }
if !hasEnd {
return out
}
complete := true
for i := range out {
end := out[i].End
if end == nil && i+1 < len(out) {
end = out[i+1].Start
}
if end == nil {
end = lineEnd
}
if end == nil {
end = nextTimedStart
}
if end != nil && i+1 < len(out) && *end > *out[i+1].Start {
end = out[i+1].Start
}
if end == nil || *end < *out[i].Start {
complete = false
break
}
out[i].End = gg.Clone(end)
}
if !complete {
for i := range out {
out[i].End = nil
}
}
return out
}
func widenLineTiming(line Line, sourceCues []Cue, nextTimedStart *int64) Line {
var earliestStart *int64
for _, cue := range line.Cue {
if cue.Start == nil {
continue
}
if earliestStart == nil || *cue.Start < *earliestStart {
earliestStart = cue.Start
}
}
if earliestStart != nil && (line.Start == nil || *earliestStart < *line.Start) {
line.Start = gg.Clone(earliestStart)
}
// Only an explicit terminal cue end is exact enough to become Line.End.
groups := make(map[string][]Cue)
for _, cue := range sourceCues {
groups[cue.AgentID] = append(groups[cue.AgentID], cue)
}
var latestExactEnd *int64
for _, group := range groups {
terminal := group[len(group)-1]
if terminal.Start == nil {
continue
}
isNextLineFallback := line.End == nil && nextTimedStart != nil && terminal.End != nil && *terminal.End == *nextTimedStart
if terminal.End != nil && !isNextLineFallback && *terminal.End >= *terminal.Start && (latestExactEnd == nil || *terminal.End > *latestExactEnd) {
latestExactEnd = terminal.End
}
}
if latestExactEnd != nil && (line.End == nil || *latestExactEnd > *line.End) {
line.End = gg.Clone(latestExactEnd)
}
if line.Start != nil && line.End != nil && *line.End < *line.Start {
line.End = nil
}
if line.Start == nil {
line.End = nil
}
return line
}
// normalizeLineTiming is used while TTML is still assembling agent metadata.
// Full cue validation and agent pruning happen when the complete Lyrics value
// is passed through NormalizeLyrics.
func normalizeLineTiming(line Line) Line {
out := cloneLyricLine(line)
if out.Start != nil && out.End != nil && *out.End < *out.Start {
out.End = nil
}
return widenLineTiming(out, out.Cue, nil)
}
func pruneLyricAgents(lines []Line, agents []Agent) []Agent {
used := make(map[string]struct{})
for _, line := range lines {
for _, cue := range line.Cue {
if cue.AgentID != "" {
used[cue.AgentID] = struct{}{}
}
}
}
if len(used) == 0 {
return nil
}
out := make([]Agent, 0, len(used))
seen := make(map[string]struct{}, len(used))
for _, agent := range agents {
id := strings.TrimSpace(agent.ID)
if _, ok := used[id]; !ok {
continue
}
if _, duplicate := seen[id]; duplicate {
continue
}
agent.ID = id
out = append(out, agent)
seen[id] = struct{}{}
}
return out
}
// NormalizeCueEnds retains the v2 response helper contract: resolve missing
// ends from the next cue or the supplied exact/fallback end without mutating
// the caller. Model parsing should use NormalizeLyrics instead.
func NormalizeCueEnds(cues []Cue, fallbackEnd *int64) []Cue { func NormalizeCueEnds(cues []Cue, fallbackEnd *int64) []Cue {
if len(cues) == 0 { if len(cues) == 0 {
return cues return cues
@ -143,12 +316,11 @@ func NormalizeCueEnds(cues []Cue, fallbackEnd *int64) []Cue {
out := slices.Clone(cues) out := slices.Clone(cues)
for i := range out { for i := range out {
end := out[i].End end := out[i].End
if end == nil && i+1 < len(out) && out[i+1].Start != nil {
end = out[i+1].Start
}
if end == nil { if end == nil {
if i+1 < len(out) && out[i+1].Start != nil { end = fallbackEnd
end = out[i+1].Start
} else {
end = fallbackEnd
}
} }
if end != nil && i+1 < len(out) && out[i+1].Start != nil && *end > *out[i+1].Start { if end != nil && i+1 < len(out) && out[i+1].Start != nil && *end > *out[i+1].Start {
end = out[i+1].Start end = out[i+1].Start
@ -160,12 +332,13 @@ func NormalizeCueEnds(cues []Cue, fallbackEnd *int64) []Cue {
} }
for i := range out { for i := range out {
if out[i].End == nil { if out[i].End != nil {
for j := range out { continue
out[j].End = nil
}
break
} }
for j := range out {
out[j].End = nil
}
break
} }
return out return out
} }

View File

@ -5,115 +5,231 @@ import (
. "github.com/onsi/gomega" . "github.com/onsi/gomega"
) )
var _ = Describe("normalizeCueLines", func() { var _ = Describe("NormalizeLyrics", func() {
It("should not mutate caller cue slices when filling missing cue end times", func() { p := func(v int64) *int64 { return &v }
start0, start1, nextLineStart := int64(1000), int64(1500), int64(3000)
lines := []Line{ It("is immutable and idempotent", func() {
{ input := Lyrics{
Start: &start0, Offset: p(25),
Value: "Some lyrics", Line: []Line{
Cue: []Cue{ {
{Start: &start0, Value: "Some ", ByteStart: 0, ByteEnd: 4}, Start: p(1000), Value: "Some lyrics",
{Start: &start1, Value: "lyrics", ByteStart: 5, ByteEnd: 10}, Cue: []Cue{
{Start: p(1000), End: p(1200), Value: "Some ", ByteStart: 0, ByteEnd: 4},
{Start: p(1500), Value: "lyrics", ByteStart: 5, ByteEnd: 10},
},
}, },
}, {Start: p(3000), Value: "Next line"},
{
Start: &nextLineStart,
Value: "Next line",
}, },
} }
before := cloneLyrics(input)
normalized := normalizeCueLines(lines) first := NormalizeLyrics(input)
second := NormalizeLyrics(first)
Expect(normalized[0].Cue[0].End).To(Equal(&start1)) Expect(first).To(Equal(second))
Expect(normalized[0].Cue[1].End).To(Equal(&nextLineStart)) Expect(input).To(Equal(before))
Expect(lines[0].Cue[0].End).To(BeNil()) Expect(first.Line[0].Cue[0].End).To(Equal(p(1200)))
Expect(lines[0].Cue[1].End).To(BeNil()) Expect(first.Line[0].Cue[1].End).To(Equal(p(3000)))
Expect(first.Line[0].End).To(BeNil(), "next-line fallback must not become an exact line end")
})
It("preserves fully start-only cue groups", func() {
lyrics := Lyrics{Line: []Line{{
Value: "hello world",
Cue: []Cue{
{Start: p(1000), Value: "hello", ByteStart: 0, ByteEnd: 4},
{Start: p(1500), Value: "world", ByteStart: 6, ByteEnd: 10},
},
}}}
out := NormalizeLyrics(lyrics)
Expect(out.Line[0].Start).To(Equal(p(1000)))
Expect(out.Line[0].End).To(BeNil())
Expect(out.Line[0].Cue[0].End).To(BeNil())
Expect(out.Line[0].Cue[1].End).To(BeNil())
})
It("scans past untimed lines for a partial cue group's fallback", func() {
lyrics := Lyrics{Line: []Line{
{
Start: p(1000), Value: "a b",
Cue: []Cue{
{Start: p(1000), End: p(1200), Value: "a", ByteStart: 0, ByteEnd: 0},
{Start: p(1500), Value: "b", ByteStart: 2, ByteEnd: 2},
},
},
{Value: "untimed"},
{Start: p(4000), Value: "timed"},
}}
out := NormalizeLyrics(lyrics)
Expect(out.Line[0].Cue[1].End).To(Equal(p(4000)))
Expect(out.Line[0].End).To(BeNil())
})
It("clears a partial group when it cannot complete consistently", func() {
lyrics := Lyrics{Line: []Line{{
Value: "a b",
Cue: []Cue{
{Start: p(1000), End: p(1200), Value: "a", ByteStart: 0, ByteEnd: 0},
{Start: p(1500), Value: "b", ByteStart: 2, ByteEnd: 2},
},
}}}
out := NormalizeLyrics(lyrics)
Expect(out.Line[0].Cue[0].End).To(BeNil())
Expect(out.Line[0].Cue[1].End).To(BeNil())
})
It("clamps overlaps only inside the same agent", func() {
lyrics := Lyrics{
Agents: []Agent{{ID: "lead"}, {ID: "backing"}},
Line: []Line{{
Value: "lead back next",
Cue: []Cue{
{Start: p(1000), End: p(2500), Value: "lead", ByteStart: 0, ByteEnd: 3, AgentID: "lead"},
{Start: p(1200), End: p(2200), Value: "back", ByteStart: 5, ByteEnd: 8, AgentID: "backing"},
{Start: p(2000), End: p(3000), Value: "next", ByteStart: 10, ByteEnd: 13, AgentID: "lead"},
},
}},
}
out := NormalizeLyrics(lyrics)
Expect(out.Line[0].Cue[0].End).To(Equal(p(2000)))
Expect(out.Line[0].Cue[1].End).To(Equal(p(2200)), "cross-agent overlap must remain")
Expect(out.Line[0].Cue[2].End).To(Equal(p(3000)))
})
It("allows different agents to reference the same byte range", func() {
lyrics := Lyrics{
Agents: []Agent{{ID: "lead"}, {ID: "backing"}},
Line: []Line{{
Value: "hello",
Cue: []Cue{
{Start: p(1000), End: p(2000), Value: "hello", ByteStart: 0, ByteEnd: 4, AgentID: "lead"},
{Start: p(1200), End: p(2200), Value: "hello", ByteStart: 0, ByteEnd: 4, AgentID: "backing"},
},
}},
}
out := NormalizeLyrics(lyrics)
Expect(out.Line[0].Cue).To(HaveLen(2))
Expect(out.Line[0].Cue[0].AgentID).To(Equal("lead"))
Expect(out.Line[0].Cue[1].AgentID).To(Equal("backing"))
})
It("ignores missing cue starts while widening partially assembled lines", func() {
line := Line{
Start: p(1000),
Value: "partial",
Cue: []Cue{{End: p(2000), Value: "partial", ByteStart: 0, ByteEnd: 6}},
}
var out Line
Expect(func() { out = normalizeLineTiming(line) }).ToNot(Panic())
Expect(out.Start).To(Equal(p(1000)))
Expect(out.End).To(BeNil())
})
It("preserves zero-duration markers and repairs reverse ends", func() {
lyrics := Lyrics{Line: []Line{
{Start: p(1000), End: p(500), Value: "marker", Cue: []Cue{{Start: p(1000), End: p(1000), Value: "marker", ByteStart: 0, ByteEnd: 5}}},
{Start: p(1500), End: p(1400), Value: "reverse"},
}}
out := NormalizeLyrics(lyrics)
Expect(out.Line[0].Cue[0].End).To(Equal(p(1000)))
Expect(out.Line[0].End).To(Equal(p(1000)))
Expect(out.Line[1].End).To(BeNil())
})
It("uses an explicit terminal cue end as an exact line end", func() {
lyrics := Lyrics{Line: []Line{{
Start: p(2000), Value: "hello",
Cue: []Cue{{Start: p(1000), End: p(2500), Value: "hello", ByteStart: 0, ByteEnd: 4}},
}}}
out := NormalizeLyrics(lyrics)
Expect(out.Line[0].Start).To(Equal(p(1000)))
Expect(out.Line[0].End).To(Equal(p(2500)))
})
DescribeTable("drops irreparable cue geometry but retains line data",
func(value string, cues []Cue, agents []Agent) {
lineStart, lineEnd := int64(1000), int64(2000)
out := NormalizeLyrics(Lyrics{
Agents: agents,
Line: []Line{{Start: &lineStart, End: &lineEnd, Value: value, Cue: cues}},
})
Expect(out.Line).To(HaveLen(1))
Expect(out.Line[0].Value).To(Equal(value))
Expect(out.Line[0].Start).To(Equal(&lineStart))
Expect(out.Line[0].End).To(Equal(&lineEnd))
Expect(out.Line[0].Cue).To(BeNil())
Expect(out.Agents).To(BeNil())
},
Entry("invalid UTF-8", string([]byte{0xff}), []Cue{{Start: p(1000), Value: string([]byte{0xff}), ByteStart: 0, ByteEnd: 0}}, nil),
Entry("range outside the line", "hi", []Cue{{Start: p(1000), Value: "hi", ByteStart: 0, ByteEnd: 2}}, nil),
Entry("range splitting a CJK rune", "한", []Cue{{Start: p(1000), Value: string([]byte("한")[:2]), ByteStart: 0, ByteEnd: 1}}, nil),
Entry("cue value mismatch", "hello", []Cue{{Start: p(1000), Value: "world", ByteStart: 0, ByteEnd: 4}}, nil),
Entry("overlapping byte ranges", "hello", []Cue{
{Start: p(1000), Value: "hel", ByteStart: 0, ByteEnd: 2},
{Start: p(1100), Value: "llo", ByteStart: 2, ByteEnd: 4},
}, nil),
Entry("out-of-order same-agent cues", "a b", []Cue{
{Start: p(1500), Value: "a", ByteStart: 0, ByteEnd: 0},
{Start: p(1000), Value: "b", ByteStart: 2, ByteEnd: 2},
}, nil),
Entry("unknown agent attribution", "hi", []Cue{{Start: p(1000), Value: "hi", ByteStart: 0, ByteEnd: 1, AgentID: "missing"}}, []Agent{{ID: "known"}}),
)
It("validates CJK, emoji, and combining-mark byte boundaries", func() {
value := "한🙂é"
lyrics := Lyrics{Line: []Line{{
Value: value,
Cue: []Cue{
{Start: p(1000), Value: "한", ByteStart: 0, ByteEnd: 2},
{Start: p(1100), Value: "🙂", ByteStart: 3, ByteEnd: 6},
{Start: p(1200), Value: "é", ByteStart: 7, ByteEnd: 9},
},
}}}
out := NormalizeLyrics(lyrics)
Expect(out.Line[0].Cue).To(HaveLen(3))
Expect([]string{out.Line[0].Cue[0].Value, out.Line[0].Cue[1].Value, out.Line[0].Cue[2].Value}).To(Equal([]string{"한", "🙂", "é"}))
})
It("prunes unused and duplicate agents", func() {
lyrics := Lyrics{
Agents: []Agent{{ID: "lead"}, {ID: "unused"}, {ID: "lead"}},
Line: []Line{{Value: "hi", Cue: []Cue{{Start: p(1000), Value: "hi", ByteStart: 0, ByteEnd: 1, AgentID: "lead"}}}},
}
out := NormalizeLyrics(lyrics)
Expect(out.Agents).To(Equal([]Agent{{ID: "lead"}}))
}) })
}) })
var _ = Describe("NormalizeCueEnds", func() { var _ = Describe("NormalizeCueEnds", func() {
// p returns a fresh pointer so cases don't share *int64 state.
p := func(v int64) *int64 { return &v } p := func(v int64) *int64 { return &v }
// endsOf extracts the resolved end times (nil-safe) for compact assertions. It("retains its response compatibility contract without mutating input", func() {
endsOf := func(cues []Cue) []*int64 { cues := []Cue{{Start: p(1000)}, {Start: p(1500)}}
out := make([]*int64, len(cues))
for i := range cues {
out[i] = cues[i].End
}
return out
}
It("returns the input as-is when empty", func() {
Expect(NormalizeCueEnds(nil, p(1000))).To(BeNil())
Expect(NormalizeCueEnds([]Cue{}, p(1000))).To(BeEmpty())
})
It("fills a missing end from the next cue's start", func() {
cues := []Cue{
{Start: p(1000)},
{Start: p(1500)},
}
out := NormalizeCueEnds(cues, p(3000)) out := NormalizeCueEnds(cues, p(3000))
Expect(endsOf(out)).To(Equal([]*int64{p(1500), p(3000)})) Expect(out[0].End).To(Equal(p(1500)))
}) Expect(out[1].End).To(Equal(p(3000)))
It("fills the last cue's missing end from fallbackEnd", func() {
cues := []Cue{
{Start: p(1000), End: p(1200)},
{Start: p(1500)},
}
out := NormalizeCueEnds(cues, p(3000))
Expect(endsOf(out)).To(Equal([]*int64{p(1200), p(3000)}))
})
It("clamps an end that overruns the next cue's start", func() {
cues := []Cue{
{Start: p(1000), End: p(9999)},
{Start: p(1500), End: p(2000)},
}
out := NormalizeCueEnds(cues, p(3000))
Expect(endsOf(out)).To(Equal([]*int64{p(1500), p(2000)}))
})
It("clamps an end that precedes the cue's own start", func() {
cues := []Cue{
{Start: p(1000), End: p(500)},
}
out := NormalizeCueEnds(cues, p(3000))
Expect(endsOf(out)).To(Equal([]*int64{p(1000)}))
})
It("clears all ends when any cue still lacks one (all-or-none)", func() {
// The last cue has no end and there is no fallback, so it stays nil and
// every end in the group is cleared.
cues := []Cue{
{Start: p(1000), End: p(1200)},
{Start: p(1500)},
}
out := NormalizeCueEnds(cues, nil)
Expect(endsOf(out)).To(Equal([]*int64{nil, nil}))
})
It("does not mutate the input slice", func() {
cues := []Cue{
{Start: p(1000)},
{Start: p(1500)},
}
_ = NormalizeCueEnds(cues, p(3000))
Expect(cues[0].End).To(BeNil()) Expect(cues[0].End).To(BeNil())
Expect(cues[1].End).To(BeNil()) Expect(cues[1].End).To(BeNil())
}) })

View File

@ -1,6 +1,7 @@
package model package model
import ( import (
"fmt"
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
@ -34,7 +35,7 @@ func parseSRT(language string, contents []byte) (LyricList, error) {
return nil, nil return nil, nil
} }
lyrics := normalizeLyrics(Lyrics{ lyrics := NormalizeLyrics(Lyrics{
Lang: normalizeLyricLang(language), Lang: normalizeLyricLang(language),
Line: lines, Line: lines,
Synced: true, Synced: true,
@ -91,6 +92,9 @@ func parseSRTBlock(block string) (Line, bool, error) {
if err != nil { if err != nil {
return Line{}, false, err return Line{}, false, err
} }
if endMs < startMs {
return Line{}, false, fmt.Errorf("SRT cue end %d precedes start %d", endMs, startMs)
}
textLines := make([]string, 0, len(lines)-startIdx-1) textLines := make([]string, 0, len(lines)-startIdx-1)
for _, line := range lines[startIdx+1:] { for _, line := range lines[startIdx+1:] {
@ -130,6 +134,9 @@ func parseSRTTime(value string) (int64, error) {
if err != nil { if err != nil {
return 0, err return 0, err
} }
if minutes > 59 || seconds > 59 {
return 0, fmt.Errorf("SRT timestamp fields out of range: %q", value)
}
millis, err := strconv.ParseInt(match[4], 10, 64) millis, err := strconv.ParseInt(match[4], 10, 64)
if err != nil { if err != nil {
return 0, err return 0, err

View File

@ -27,4 +27,27 @@ var _ = Describe("parseSRT", func() {
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(list).To(BeNil()) Expect(list).To(BeNil())
}) })
It("preserves zero durations, overlaps, and document order", func() {
content := []byte("1\n00:00:05,000 --> 00:00:05,000\nMarker\n\n2\n00:00:03,000 --> 00:00:06,000\nEarlier overlapping line")
list, err := parseSRT("eng", content)
Expect(err).ToNot(HaveOccurred())
Expect(list[0].Line).To(Equal([]Line{
{Start: new(int64(5000)), End: new(int64(5000)), Value: "Marker"},
{Start: new(int64(3000)), End: new(int64(6000)), Value: "Earlier overlapping line"},
}))
})
DescribeTable("rejects invalid SRT timing",
func(timing string) {
list, err := parseSRT("eng", []byte("1\n"+timing+"\nInvalid"))
Expect(err).To(HaveOccurred())
Expect(list).To(BeNil())
},
Entry("negative duration", "00:00:02,000 --> 00:00:01,999"),
Entry("minute out of range", "00:60:00,000 --> 01:00:01,000"),
Entry("second out of range", "00:00:60,000 --> 00:01:01,000"),
)
}) })

View File

@ -78,9 +78,10 @@ type ttmlDefinedAgent struct {
} }
type ttmlPiece struct { type ttmlPiece struct {
raw string raw string
cue *Cue cue *Cue
isBreak bool isBreak bool
invalidTiming bool
} }
type ttmlParser struct { type ttmlParser struct {
@ -189,20 +190,20 @@ func (p *ttmlParser) parseElement(start xml.StartElement, parent ttmlTimingConte
if err != nil { if err != nil {
return err return err
} }
if ctx.invalid || lineText == "" { if lineText == "" {
return nil return nil
} }
parsedLine := Line{Value: lineText} parsedLine := Line{Value: lineText}
if ctx.hasBegin { if !ctx.invalid && ctx.hasBegin {
startMs := ctx.begin startMs := ctx.begin
parsedLine.Start = &startMs parsedLine.Start = &startMs
} }
if ctx.hasEnd { if !ctx.invalid && ctx.hasEnd {
endMs := ctx.end endMs := ctx.end
parsedLine.End = &endMs parsedLine.End = &endMs
} }
if len(tokens) > 0 { if !ctx.invalid && len(tokens) > 0 {
parsedLine.Cue = tokens parsedLine.Cue = tokens
} }
parsedLine = normalizeLineTiming(parsedLine) parsedLine = normalizeLineTiming(parsedLine)
@ -331,21 +332,17 @@ func (p *ttmlParser) parseMetadataText(start xml.StartElement, parent ttmlTiming
} }
ctx := p.childContext(start.Attr, parent) ctx := p.childContext(start.Attr, parent)
if ctx.invalid {
return ttmlMetadataEntry{}, false, nil
}
value, tokens := buildTTMLLineFromPieces(pieces) value, tokens := buildTTMLLineFromPieces(pieces)
line := Line{Value: value} line := Line{Value: value}
if ctx.hasBegin { if !ctx.invalid && ctx.hasBegin {
startMs := ctx.begin startMs := ctx.begin
line.Start = &startMs line.Start = &startMs
} }
if ctx.hasEnd { if !ctx.invalid && ctx.hasEnd {
endMs := ctx.end endMs := ctx.end
line.End = &endMs line.End = &endMs
} }
if len(tokens) > 0 { if !ctx.invalid && len(tokens) > 0 {
line.Cue = tokens line.Cue = tokens
} }
line = normalizeLineTiming(line) line = normalizeLineTiming(line)
@ -416,7 +413,17 @@ func (p *ttmlParser) parseInlineElement(start xml.StartElement, parent ttmlTimin
continue continue
} }
if local == "span" && hasOwnTiming && !ctx.invalid && !ttmlPiecesContainCue(pieces) { if local == "span" && hasOwnTiming && ctx.invalid {
if len(pieces) == 0 {
pieces = append(pieces, ttmlPiece{})
}
for i := range pieces {
pieces[i].invalidTiming = true
}
return pieces, nil
}
if local == "span" && hasOwnTiming && !ttmlPiecesContainCue(pieces) {
rawValue := concatTTMLPieceRaw(pieces) rawValue := concatTTMLPieceRaw(pieces)
tokenText := sanitizeTTMLText(rawValue) tokenText := sanitizeTTMLText(rawValue)
if tokenText != "" { if tokenText != "" {
@ -447,6 +454,10 @@ func (p *ttmlParser) parseInlineElement(start xml.StartElement, parent ttmlTimin
} }
func buildTTMLLineFromPieces(pieces []ttmlPiece) (string, []Cue) { func buildTTMLLineFromPieces(pieces []ttmlPiece) (string, []Cue) {
invalidTiming := false
for _, piece := range pieces {
invalidTiming = invalidTiming || piece.invalidTiming
}
finalized := finalizeTTMLLines(splitTTMLPiecesByBreak(pieces)) finalized := finalizeTTMLLines(splitTTMLPiecesByBreak(pieces))
for len(finalized) > 0 && finalized[0].text == "" && len(finalized[0].cues) == 0 { for len(finalized) > 0 && finalized[0].text == "" && len(finalized[0].cues) == 0 {
finalized = finalized[1:] finalized = finalized[1:]
@ -476,6 +487,9 @@ func buildTTMLLineFromPieces(pieces []ttmlPiece) (string, []Cue) {
byteOffset += len(line.text) byteOffset += len(line.text)
} }
if invalidTiming {
cues = nil
}
return value.String(), cues return value.String(), cues
} }
@ -513,8 +527,9 @@ func splitTTMLPiecesByBreak(pieces []ttmlPiece) [][]ttmlPiece {
continue continue
} }
lines[len(lines)-1] = append(lines[len(lines)-1], ttmlPiece{ lines[len(lines)-1] = append(lines[len(lines)-1], ttmlPiece{
raw: raw, raw: raw,
cue: gg.Clone(piece.cue), cue: gg.Clone(piece.cue),
invalidTiming: piece.invalidTiming,
}) })
prevEndedWithSpace = strings.HasSuffix(raw, " ") prevEndedWithSpace = strings.HasSuffix(raw, " ")
} }
@ -701,7 +716,7 @@ func (p *ttmlParser) buildMetadataLyrics(kind string, langOrder []string, entrie
func (p *ttmlParser) finalizeLyrics(lyrics Lyrics) Lyrics { func (p *ttmlParser) finalizeLyrics(lyrics Lyrics) Lyrics {
lyrics.Line, lyrics.Agents = p.resolveAgents(lyrics.Line) lyrics.Line, lyrics.Agents = p.resolveAgents(lyrics.Line)
return normalizeLyrics(lyrics) return NormalizeLyrics(lyrics)
} }
func (p *ttmlParser) resolveAgents(lines []Line) ([]Line, []Agent) { func (p *ttmlParser) resolveAgents(lines []Line) ([]Line, []Agent) {
@ -979,6 +994,9 @@ func (p *ttmlParser) childContext(attrs []xml.Attr, parent ttmlTimingContext) tt
ctx.end = calculatedEnd ctx.end = calculatedEnd
ctx.hasEnd = calculatedHasEnd ctx.hasEnd = calculatedHasEnd
if ctx.hasBegin && ctx.hasEnd && ctx.end < ctx.begin {
ctx.invalid = true
}
return ctx return ctx
} }

View File

@ -43,7 +43,7 @@ var _ = Describe("parseTTML", func() {
}) })
Describe("Unsupported cue handling", func() { Describe("Unsupported cue handling", func() {
It("should skip wallclock cues and keep valid ones", func() { It("should retain text with invalid wallclock timing and keep valid cues", func() {
content := []byte(`<?xml version="1.0" encoding="UTF-8"?> content := []byte(`<?xml version="1.0" encoding="UTF-8"?>
<tt xmlns="http://www.w3.org/ns/ttml"> <tt xmlns="http://www.w3.org/ns/ttml">
<body xml:lang="eng"> <body xml:lang="eng">
@ -57,9 +57,11 @@ var _ = Describe("parseTTML", func() {
list, err := parseTTML("xxx", content) list, err := parseTTML("xxx", content)
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(list).To(HaveLen(1)) Expect(list).To(HaveLen(1))
Expect(list[0].Line).To(HaveLen(1)) Expect(list[0].Line).To(HaveLen(2))
Expect(list[0].Line[0].Start).To(Equal(new(int64(1000)))) Expect(list[0].Line[0].Start).To(BeNil())
Expect(list[0].Line[0].Value).To(Equal("Keep me")) Expect(list[0].Line[0].Value).To(Equal("Skip me"))
Expect(list[0].Line[1].Start).To(Equal(new(int64(1000))))
Expect(list[0].Line[1].Value).To(Equal("Keep me"))
}) })
}) })
@ -548,4 +550,45 @@ var _ = Describe("parseTTML", func() {
Expect(line.Cue[2]).To(Equal(Cue{Start: new(int64(3179)), End: new(int64(3582)), Value: "up", ByteStart: 7, ByteEnd: 8})) Expect(line.Cue[2]).To(Equal(Cue{Start: new(int64(3179)), End: new(int64(3582)), Value: "up", ByteStart: 7, ByteEnd: 8}))
}) })
}) })
It("retains line text but drops enhanced cues for invalid paragraph timing", func() {
content := []byte(`<tt xmlns="http://www.w3.org/ns/ttml"><body><div>
<p begin="00:00:02.000" end="00:00:01.000"><span begin="00:00:02.000" end="00:00:03.000">Still visible</span></p>
</div></body></tt>`)
list, err := parseTTML("eng", content)
Expect(err).ToNot(HaveOccurred())
Expect(list).To(HaveLen(1))
Expect(list[0].Line).To(Equal([]Line{{Value: "Still visible"}}))
})
It("drops only the affected line's cues for invalid span timing", func() {
content := []byte(`<tt xmlns="http://www.w3.org/ns/ttml"><body><div>
<p begin="1s" end="3s"><span begin="1s" end="2s">Good</span> <span begin="wallclock(now)">text</span></p>
<p begin="4s" end="5s"><span begin="4s" end="5s">Timed</span></p>
</div></body></tt>`)
list, err := parseTTML("eng", content)
Expect(err).ToNot(HaveOccurred())
Expect(list).To(HaveLen(1))
Expect(list[0].Line).To(HaveLen(2))
Expect(list[0].Line[0].Value).To(Equal("Good text"))
Expect(list[0].Line[0].Cue).To(BeNil())
Expect(list[0].Line[1].Value).To(Equal("Timed"))
Expect(list[0].Line[1].Cue).To(HaveLen(1))
})
It("retains text when an end-only span has no usable cue start", func() {
content := []byte(`<tt xmlns="http://www.w3.org/ns/ttml"><body><div>
<p><span end="2s">Still visible</span></p>
</div></body></tt>`)
list, err := parseTTML("eng", content)
Expect(err).ToNot(HaveOccurred())
Expect(list).To(HaveLen(1))
Expect(list[0].Line).To(Equal([]Line{{Value: "Still visible"}}))
})
}) })