diff --git a/core/lyrics/ttml.go b/core/lyrics/ttml.go index 6e4ce9da3..576d2ca3d 100644 --- a/core/lyrics/ttml.go +++ b/core/lyrics/ttml.go @@ -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 { diff --git a/core/lyrics/ttml_test.go b/core/lyrics/ttml_test.go index 5f9092e36..14676975d 100644 --- a/core/lyrics/ttml_test.go +++ b/core/lyrics/ttml_test.go @@ -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(` + + + + Guest Vocal + + + +
+

+ Lead + Guest +

+
+ +
`) + + 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() { diff --git a/server/subsonic/media_retrieval_test.go b/server/subsonic/media_retrieval_test.go index d02d5b9bd..aedf08ff7 100644 --- a/server/subsonic/media_retrieval_test.go +++ b/server/subsonic/media_retrieval_test.go @@ -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") diff --git a/ui/src/audioplayer/lyrics.js b/ui/src/audioplayer/lyrics.js index ae49c89e5..b44e4d9f0 100644 --- a/ui/src/audioplayer/lyrics.js +++ b/ui/src/audioplayer/lyrics.js @@ -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) diff --git a/ui/src/audioplayer/lyrics.test.js b/ui/src/audioplayer/lyrics.test.js index ae5fb5a66..1abea57a5 100644 --- a/ui/src/audioplayer/lyrics.test.js +++ b/ui/src/audioplayer/lyrics.test.js @@ -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',