refactor(lyrics): clean up karaoke parsing and edge cases

This commit is contained in:
ranokay 2026-04-14 16:17:38 +03:00
parent 7a6b398968
commit 45fac62285
No known key found for this signature in database
5 changed files with 218 additions and 73 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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',