mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
refactor(lyrics): clean up karaoke parsing and edge cases
This commit is contained in:
parent
7a6b398968
commit
45fac62285
@ -664,7 +664,6 @@ func (p *ttmlParser) buildMetadataLyrics(kind string, langOrder []string, entrie
|
||||
}
|
||||
|
||||
func (p *ttmlParser) finalizeLyrics(lyrics model.Lyrics) model.Lyrics {
|
||||
lyrics.Line = model.NormalizeCueLines(lyrics.Line)
|
||||
lyrics.Line, lyrics.Agents = p.resolveAgents(lyrics.Line)
|
||||
return model.NormalizeLyrics(lyrics)
|
||||
}
|
||||
@ -674,14 +673,13 @@ func (p *ttmlParser) resolveAgents(lines []model.Line) ([]model.Line, []model.Ag
|
||||
return lines, nil
|
||||
}
|
||||
|
||||
normalized := model.NormalizeCueLines(lines)
|
||||
usedOrder := make([]string, 0, 4)
|
||||
usedSet := make(map[string]struct{}, 4)
|
||||
sawEmptyCue := false
|
||||
|
||||
for i := range normalized {
|
||||
for j := range normalized[i].Cue {
|
||||
agentID := strings.TrimSpace(normalized[i].Cue[j].AgentID)
|
||||
for i := range lines {
|
||||
for j := range lines[i].Cue {
|
||||
agentID := strings.TrimSpace(lines[i].Cue[j].AgentID)
|
||||
if agentID == "" {
|
||||
sawEmptyCue = true
|
||||
continue
|
||||
@ -694,7 +692,7 @@ func (p *ttmlParser) resolveAgents(lines []model.Line) ([]model.Line, []model.Ag
|
||||
}
|
||||
|
||||
if len(usedOrder) == 0 {
|
||||
return normalized, nil
|
||||
return lines, nil
|
||||
}
|
||||
|
||||
mainID := ""
|
||||
@ -725,10 +723,10 @@ func (p *ttmlParser) resolveAgents(lines []model.Line) ([]model.Line, []model.Ag
|
||||
usedOrder = append([]string{mainID}, usedOrder...)
|
||||
}
|
||||
|
||||
for i := range normalized {
|
||||
for j := range normalized[i].Cue {
|
||||
if strings.TrimSpace(normalized[i].Cue[j].AgentID) == "" {
|
||||
normalized[i].Cue[j].AgentID = mainID
|
||||
for i := range lines {
|
||||
for j := range lines[i].Cue {
|
||||
if strings.TrimSpace(lines[i].Cue[j].AgentID) == "" {
|
||||
lines[i].Cue[j].AgentID = mainID
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -747,7 +745,7 @@ func (p *ttmlParser) resolveAgents(lines []model.Line) ([]model.Line, []model.Ag
|
||||
agents = append(agents, agent)
|
||||
}
|
||||
|
||||
return normalized, agents
|
||||
return lines, agents
|
||||
}
|
||||
|
||||
func (p *ttmlParser) resolveCueAgentID(ctx ttmlTimingContext) string {
|
||||
|
||||
@ -215,6 +215,36 @@ var _ = Describe("parseTTML", func() {
|
||||
Expect(list[0].Line[1].Cue).To(HaveLen(1))
|
||||
Expect(list[0].Line[1].Cue[0].AgentID).To(Equal("lead__bg"))
|
||||
})
|
||||
|
||||
It("should fill missing cue agent ids with the resolved main agent", func() {
|
||||
content := []byte(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<tt xmlns="http://www.w3.org/ns/ttml" xmlns:ttm="http://www.w3.org/ns/ttml#metadata">
|
||||
<head>
|
||||
<metadata>
|
||||
<ttm:agent xml:id="guest" type="person"><ttm:name>Guest Vocal</ttm:name></ttm:agent>
|
||||
</metadata>
|
||||
</head>
|
||||
<body xml:lang="eng">
|
||||
<div>
|
||||
<p begin="1s" end="3s">
|
||||
<span begin="1s" end="1.4s">Lead</span>
|
||||
<span begin="2s" end="2.4s" ttm:agent="guest">Guest</span>
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</tt>`)
|
||||
|
||||
list, err := parseTTML(content)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(list).To(HaveLen(1))
|
||||
Expect(list[0].Agents).To(Equal([]model.Agent{
|
||||
{ID: "guest", Role: "main", Name: "Guest Vocal"},
|
||||
}))
|
||||
Expect(list[0].Line).To(HaveLen(1))
|
||||
Expect(list[0].Line[0].Cue).To(HaveLen(2))
|
||||
Expect(list[0].Line[0].Cue[0].AgentID).To(Equal("guest"))
|
||||
Expect(list[0].Line[0].Cue[1].AgentID).To(Equal("guest"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Ambiguous decimal timing", func() {
|
||||
|
||||
@ -653,6 +653,57 @@ var _ = Describe("MediaRetrievalController", func() {
|
||||
})
|
||||
})
|
||||
|
||||
It("should keep enhanced line-level lyrics when no cue data is available", func() {
|
||||
r := newGetRequest("id=1&enhanced=true")
|
||||
|
||||
lineStart := int64(1000)
|
||||
lineEnd := int64(3000)
|
||||
lyricsJSON, err := json.Marshal(model.LyricList{
|
||||
{
|
||||
Kind: "main",
|
||||
Lang: "eng",
|
||||
Synced: true,
|
||||
Line: []model.Line{
|
||||
{
|
||||
Start: &lineStart,
|
||||
End: &lineEnd,
|
||||
Value: "Line without word timing",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
mockRepo.SetData(model.MediaFiles{
|
||||
{
|
||||
ID: "1",
|
||||
Artist: "Rick Astley",
|
||||
Title: "Never Gonna Give You Up",
|
||||
Lyrics: string(lyricsJSON),
|
||||
},
|
||||
})
|
||||
|
||||
response, err := router.GetLyricsBySongId(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
compareResponses(response.LyricsList, responses.LyricsList{
|
||||
StructuredLyrics: responses.StructuredLyrics{
|
||||
{
|
||||
DisplayArtist: "Rick Astley",
|
||||
DisplayTitle: "Never Gonna Give You Up",
|
||||
Kind: "main",
|
||||
Lang: "eng",
|
||||
Synced: true,
|
||||
Line: []responses.Line{
|
||||
{
|
||||
Start: &lineStart,
|
||||
Value: "Line without word timing",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
It("should return required cue byte offsets for ambiguous and multibyte cue lines", func() {
|
||||
r := newGetRequest("id=1&enhanced=true")
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
const normalizeLanguageTag = (language) =>
|
||||
(language || '').toLowerCase().replace('_', '-')
|
||||
|
||||
// Roughly one 60fps frame; keeps line/token switching stable near tight boundaries.
|
||||
const KARAOKE_SWITCH_EPSILON_MS = 18
|
||||
const LYRIC_KIND_MAIN = 'main'
|
||||
const LYRIC_KIND_TRANSLATION = 'translation'
|
||||
@ -379,6 +380,68 @@ export const structuredLyricsToLrc = (structuredLyrics, preferredLanguage) => {
|
||||
return structuredLyricToLrc(selected)
|
||||
}
|
||||
|
||||
const buildBaseKaraokeLines = (baseLines) =>
|
||||
baseLines.map((line, index) => ({
|
||||
index,
|
||||
start: toTime(line.start),
|
||||
end: toTime(line.end),
|
||||
value: typeof line.value === 'string' ? line.value : '',
|
||||
tokens: [],
|
||||
}))
|
||||
|
||||
export const buildKaraokeLinesFromCueLines = (
|
||||
rawCueLines,
|
||||
baseLines,
|
||||
agentLookup,
|
||||
) => {
|
||||
const normalizedCueLines = rawCueLines.map((cueLine, fallbackIndex) => {
|
||||
const normalized = normalizeCueLine(cueLine, fallbackIndex, agentLookup)
|
||||
return {
|
||||
...normalized,
|
||||
tokens: normalized.tokens.map((token) => ({
|
||||
...token,
|
||||
role: normalized.role,
|
||||
agentId: normalized.agentId,
|
||||
agentName: normalized.agentName,
|
||||
agentRole: normalized.agentRole,
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
const byIndex = new Map()
|
||||
for (const cueLine of normalizedCueLines) {
|
||||
if (!byIndex.has(cueLine.index)) {
|
||||
byIndex.set(cueLine.index, [])
|
||||
}
|
||||
byIndex.get(cueLine.index).push(cueLine)
|
||||
}
|
||||
|
||||
return Array.from(byIndex.entries()).map(([index, group]) => {
|
||||
const first = group[0]
|
||||
const baseLine = baseLines[index] || {}
|
||||
const tokens = sortTokensByStart(group.flatMap((cueLine) => cueLine.tokens))
|
||||
const fallbackStart =
|
||||
tokens.find((token) => token.start != null)?.start ?? null
|
||||
const fallbackEnd =
|
||||
[...tokens].reverse().find((token) => token.end != null)?.end ?? null
|
||||
const value =
|
||||
first.value ||
|
||||
(typeof baseLine.value === 'string' ? baseLine.value : '') ||
|
||||
tokens.map((token) => token.value).join('')
|
||||
|
||||
return {
|
||||
index,
|
||||
start: first.start ?? toTime(baseLine.start) ?? fallbackStart,
|
||||
end: first.end ?? toTime(baseLine.end) ?? fallbackEnd,
|
||||
value,
|
||||
agentId: first.agentId,
|
||||
agentName: first.agentName,
|
||||
agentRole: first.agentRole,
|
||||
tokens,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const buildKaraokeLines = (structuredLyric) => {
|
||||
if (!structuredLyric) {
|
||||
return []
|
||||
@ -394,68 +457,8 @@ export const buildKaraokeLines = (structuredLyric) => {
|
||||
|
||||
const lines =
|
||||
rawCueLines.length > 0
|
||||
? (() => {
|
||||
const normalizedCueLines = rawCueLines.map(
|
||||
(cueLine, fallbackIndex) => {
|
||||
const normalized = normalizeCueLine(
|
||||
cueLine,
|
||||
fallbackIndex,
|
||||
agentLookup,
|
||||
)
|
||||
return {
|
||||
...normalized,
|
||||
tokens: normalized.tokens.map((token) => ({
|
||||
...token,
|
||||
role: normalized.role,
|
||||
agentId: normalized.agentId,
|
||||
agentName: normalized.agentName,
|
||||
agentRole: normalized.agentRole,
|
||||
})),
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const byIndex = new Map()
|
||||
for (const cl of normalizedCueLines) {
|
||||
if (!byIndex.has(cl.index)) {
|
||||
byIndex.set(cl.index, [])
|
||||
}
|
||||
byIndex.get(cl.index).push(cl)
|
||||
}
|
||||
|
||||
return Array.from(byIndex.entries()).map(([index, group]) => {
|
||||
const first = group[0]
|
||||
const baseLine = baseLines[index] || {}
|
||||
const tokens = sortTokensByStart(group.flatMap((cl) => cl.tokens))
|
||||
const fallbackStart =
|
||||
tokens.find((token) => token.start != null)?.start ?? null
|
||||
const fallbackEnd =
|
||||
[...tokens].reverse().find((token) => token.end != null)?.end ??
|
||||
null
|
||||
const value =
|
||||
first.value ||
|
||||
(typeof baseLine.value === 'string' ? baseLine.value : '') ||
|
||||
tokens.map((token) => token.value).join('')
|
||||
|
||||
return {
|
||||
index,
|
||||
start: first.start ?? toTime(baseLine.start) ?? fallbackStart,
|
||||
end: first.end ?? toTime(baseLine.end) ?? fallbackEnd,
|
||||
value,
|
||||
agentId: first.agentId,
|
||||
agentName: first.agentName,
|
||||
agentRole: first.agentRole,
|
||||
tokens,
|
||||
}
|
||||
})
|
||||
})()
|
||||
: baseLines.map((line, index) => ({
|
||||
index,
|
||||
start: toTime(line.start),
|
||||
end: toTime(line.end),
|
||||
value: typeof line.value === 'string' ? line.value : '',
|
||||
tokens: [],
|
||||
}))
|
||||
? buildKaraokeLinesFromCueLines(rawCueLines, baseLines, agentLookup)
|
||||
: buildBaseKaraokeLines(baseLines)
|
||||
|
||||
const normalized = lines
|
||||
.filter((line) => line.value || line.tokens.length > 0)
|
||||
|
||||
@ -2,6 +2,7 @@ import {
|
||||
buildHighlightedAuxLine,
|
||||
buildHighlightedMainLine,
|
||||
buildKaraokeLines,
|
||||
buildKaraokeLinesFromCueLines,
|
||||
findLayerLineIndexForMain,
|
||||
getActiveKaraokeState,
|
||||
getPreferredLyricLanguage,
|
||||
@ -414,6 +415,68 @@ describe('lyrics helpers', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('builds grouped karaoke lines directly from cue lines', () => {
|
||||
const agentLookup = new Map([
|
||||
['lead', { id: 'lead', role: 'main', name: 'Lead Vocal' }],
|
||||
['backing', { id: 'backing', role: 'bg', name: '' }],
|
||||
])
|
||||
|
||||
const lines = buildKaraokeLinesFromCueLines(
|
||||
[
|
||||
{
|
||||
index: 0,
|
||||
start: 1000,
|
||||
end: 3000,
|
||||
value: 'Hello world',
|
||||
agentId: 'lead',
|
||||
cue: [{ start: 1000, end: 1500, value: 'Hello' }],
|
||||
},
|
||||
{
|
||||
index: 0,
|
||||
start: 1000,
|
||||
end: 3000,
|
||||
value: 'Hello world',
|
||||
agentId: 'backing',
|
||||
cue: [{ start: 2000, end: 2500, value: 'world' }],
|
||||
},
|
||||
],
|
||||
[{ start: 1000, end: 3000, value: 'Hello world' }],
|
||||
agentLookup,
|
||||
)
|
||||
|
||||
expect(lines).toEqual([
|
||||
{
|
||||
agentId: 'lead',
|
||||
agentName: 'Lead Vocal',
|
||||
agentRole: 'main',
|
||||
index: 0,
|
||||
start: 1000,
|
||||
end: 3000,
|
||||
value: 'Hello world',
|
||||
tokens: [
|
||||
{
|
||||
start: 1000,
|
||||
end: 1500,
|
||||
value: 'Hello',
|
||||
role: '',
|
||||
agentId: 'lead',
|
||||
agentName: 'Lead Vocal',
|
||||
agentRole: 'main',
|
||||
},
|
||||
{
|
||||
start: 2000,
|
||||
end: 2500,
|
||||
value: 'world',
|
||||
role: 'bg',
|
||||
agentId: 'backing',
|
||||
agentName: '',
|
||||
agentRole: 'bg',
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves cue byte offsets on karaoke tokens', () => {
|
||||
const lines = buildKaraokeLines({
|
||||
lang: 'eng',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user