diff --git a/core/lyrics/sources_test.go b/core/lyrics/sources_test.go index 8823a3175..3dd2825e6 100644 --- a/core/lyrics/sources_test.go +++ b/core/lyrics/sources_test.go @@ -88,6 +88,43 @@ var _ = Describe("sources", func() { })) }) + It("should return Enhanced LRC lyrics with word-level cues from a file", func() { + mf := model.MediaFile{Path: "tests/fixtures/test-enhanced.mp3"} + lyrics, err := fromExternalFile(ctx, &mf, ".lrc") + + Expect(err).To(BeNil()) + Expect(lyrics).To(HaveLen(1)) + Expect(lyrics[0].DisplayArtist).To(Equal("Test Artist")) + Expect(lyrics[0].DisplayTitle).To(Equal("Enhanced Test")) + Expect(lyrics[0].Lang).To(Equal("eng")) + Expect(lyrics[0].Synced).To(BeTrue()) + Expect(lyrics[0].Line).To(HaveLen(3)) + + // Line 1: has inline markers → Cue array populated + Expect(lyrics[0].Line[0].Start).To(Equal(gg.P(int64(1000)))) + Expect(lyrics[0].Line[0].Value).To(Equal("Some lyrics here")) + Expect(lyrics[0].Line[0].Cue).To(HaveLen(3)) + Expect(*lyrics[0].Line[0].Cue[0].Start).To(Equal(int64(1000))) + Expect(lyrics[0].Line[0].Cue[0].Value).To(Equal("Some ")) + Expect(*lyrics[0].Line[0].Cue[0].End).To(Equal(int64(1500))) + Expect(*lyrics[0].Line[0].Cue[1].Start).To(Equal(int64(1500))) + Expect(lyrics[0].Line[0].Cue[1].Value).To(Equal("lyrics ")) + Expect(*lyrics[0].Line[0].Cue[1].End).To(Equal(int64(2000))) + Expect(*lyrics[0].Line[0].Cue[2].Start).To(Equal(int64(2000))) + Expect(lyrics[0].Line[0].Cue[2].Value).To(Equal("here")) + Expect(lyrics[0].Line[0].Cue[2].End).To(BeNil()) + + // Line 2: has inline markers + Expect(lyrics[0].Line[1].Start).To(Equal(gg.P(int64(3000)))) + Expect(lyrics[0].Line[1].Value).To(Equal("More words")) + Expect(lyrics[0].Line[1].Cue).To(HaveLen(2)) + + // Line 3: plain line, no cues + Expect(lyrics[0].Line[2].Start).To(Equal(gg.P(int64(5000)))) + Expect(lyrics[0].Line[2].Value).To(Equal("Plain line without inline markers")) + Expect(lyrics[0].Line[2].Cue).To(BeNil()) + }) + It("should return unsynchronized lyrics from a file", func() { mf := model.MediaFile{Path: "tests/fixtures/test.mp3"} lyrics, err := fromExternalFile(ctx, &mf, ".txt") diff --git a/model/lyrics.go b/model/lyrics.go index 3cb1cb715..9fcd4992e 100644 --- a/model/lyrics.go +++ b/model/lyrics.go @@ -43,6 +43,10 @@ var ( syncRegex = regexp.MustCompile(`(^|\n)\s*` + timeRegexString) timeRegex = regexp.MustCompile(timeRegexString) lrcIdRegex = regexp.MustCompile(`\[(ar|ti|offset|lang):([^]]+)]`) + + // Enhanced LRC: inline word-level timing markers like <00:12.34> + enhancedLRCTimeString = `<([0-9]{1,2}:)?([0-9]{1,2}):([0-9]{1,2})(.[0-9]{1,3})?>` + enhancedLRCRegex = regexp.MustCompile(enhancedLRCTimeString) ) func (l Lyrics) IsEmpty() bool { @@ -116,9 +120,15 @@ func ToLyrics(language, text string) (*Lyrics, error) { if validLine { for idx := range timestamps { + cues := parseEnhancedCues(priorLine) + value := priorLine + if cues != nil { + value = stripEnhancedMarkers(value) + } structuredLines = append(structuredLines, Line{ Start: ×tamps[idx], - Value: strings.TrimSpace(priorLine), + Value: strings.TrimSpace(value), + Cue: cues, }) } timestamps = nil @@ -164,9 +174,15 @@ func ToLyrics(language, text string) (*Lyrics, error) { if validLine { for idx := range timestamps { + cues := parseEnhancedCues(priorLine) + value := priorLine + if cues != nil { + value = stripEnhancedMarkers(value) + } structuredLines = append(structuredLines, Line{ Start: ×tamps[idx], - Value: strings.TrimSpace(priorLine), + Value: strings.TrimSpace(value), + Cue: cues, }) } } @@ -190,6 +206,91 @@ func ToLyrics(language, text string) (*Lyrics, error) { return &lyrics, nil } +// parseEnhancedCues extracts word-level timing cues from Enhanced LRC inline markers. +// Format: word word ... +// Returns nil if no inline markers are found. +func parseEnhancedCues(text string) []Cue { + matches := enhancedLRCRegex.FindAllStringSubmatchIndex(text, -1) + if len(matches) == 0 { + return nil + } + + type segment struct { + start int64 + text string + } + + segments := make([]segment, 0, len(matches)) + for i, match := range matches { + timeMs, err := parseTime( + // Rewrite <...> as [...] so parseTime can handle it with the same logic + "["+text[match[0]+1:match[1]-1]+"]", + // Adjust match indices to point into our rewritten string (need start/end pairs for each group) + []int{ + 0, match[1] - match[0], + adjustGroup(match, 2), adjustGroup(match, 3), + adjustGroup(match, 4), adjustGroup(match, 5), + adjustGroup(match, 6), adjustGroup(match, 7), + adjustGroup(match, 8), adjustGroup(match, 9), + }, + ) + if err != nil { + continue + } + + // Text runs from after this marker to the start of the next marker (or end of string) + textStart := match[1] + var textEnd int + if i+1 < len(matches) { + textEnd = matches[i+1][0] + } else { + textEnd = len(text) + } + + word := text[textStart:textEnd] + if word == "" { + continue + } + segments = append(segments, segment{start: timeMs, text: word}) + } + + if len(segments) == 0 { + return nil + } + + cues := make([]Cue, len(segments)) + for i, seg := range segments { + start := seg.start + cues[i] = Cue{ + Start: &start, + Value: seg.text, + } + // Derive End from the next cue's Start + if i+1 < len(segments) { + end := segments[i+1].start + cues[i].End = &end + } + } + return cues +} + +// adjustGroup remaps a capture group index from the original match to our rewritten "[...]" string. +// The rewrite shifts by -1 (removed '<', added '[') so positions within the brackets stay the same. +func adjustGroup(match []int, groupIdx int) int { + orig := match[groupIdx] + if orig == -1 { + return -1 + } + // Offset is: original position minus the position of '<' in the original, plus 1 for '[' + return orig - match[0] +} + +// stripEnhancedMarkers removes all inline markers from text, +// returning the plain lyric text. +func stripEnhancedMarkers(text string) string { + return enhancedLRCRegex.ReplaceAllString(text, "") +} + func parseTime(line string, match []int) (int64, error) { var hours, millis int64 var err error diff --git a/model/lyrics_test.go b/model/lyrics_test.go index 382976872..2228306d0 100644 --- a/model/lyrics_test.go +++ b/model/lyrics_test.go @@ -116,4 +116,63 @@ var _ = Describe("ToLyrics", func() { {Start: &e, Value: "Test"}, })) }) + + It("should parse Enhanced LRC with word-level timing", func() { + lyrics, err := ToLyrics("xxx", "[00:01.00]<00:01.00>Some <00:01.50>lyrics <00:02.00>here\n[00:03.00]<00:03.00>More <00:03.50>words") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Synced).To(BeTrue()) + Expect(lyrics.Line).To(HaveLen(2)) + + t1000, t1500, t2000, t3000, t3500 := int64(1000), int64(1500), int64(2000), int64(3000), int64(3500) + + line0 := lyrics.Line[0] + Expect(line0.Start).To(Equal(&t1000)) + Expect(line0.Value).To(Equal("Some lyrics here")) + Expect(line0.Cue).To(Equal([]Cue{ + {Start: &t1000, End: &t1500, Value: "Some "}, + {Start: &t1500, End: &t2000, Value: "lyrics "}, + {Start: &t2000, Value: "here"}, + })) + + line1 := lyrics.Line[1] + Expect(line1.Start).To(Equal(&t3000)) + Expect(line1.Value).To(Equal("More words")) + Expect(line1.Cue).To(Equal([]Cue{ + {Start: &t3000, End: &t3500, Value: "More "}, + {Start: &t3500, Value: "words"}, + })) + }) + + It("should ignore Enhanced LRC markers and return plain lines when no markers present", func() { + a, b := int64(1000), int64(3000) + lyrics, err := ToLyrics("xxx", "[00:01.00]Plain line\n[00:03.00]Another plain line") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Line).To(Equal([]Line{ + {Start: &a, Value: "Plain line"}, + {Start: &b, Value: "Another plain line"}, + })) + }) + + It("should handle mixed Enhanced and plain LRC lines", func() { + lyrics, err := ToLyrics("xxx", "[00:01.00]<00:01.00>Some <00:01.50>lyrics\n[00:03.00]Plain line\n[00:05.00]<00:05.00>More <00:05.50>words") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Line).To(HaveLen(3)) + + t1000, t1500, t5000, t5500 := int64(1000), int64(1500), int64(5000), int64(5500) + + Expect(lyrics.Line[0].Cue).To(Equal([]Cue{ + {Start: &t1000, End: &t1500, Value: "Some "}, + {Start: &t1500, Value: "lyrics"}, + })) + Expect(lyrics.Line[0].Value).To(Equal("Some lyrics")) + + Expect(lyrics.Line[1].Cue).To(BeNil()) + Expect(lyrics.Line[1].Value).To(Equal("Plain line")) + + Expect(lyrics.Line[2].Cue).To(Equal([]Cue{ + {Start: &t5000, End: &t5500, Value: "More "}, + {Start: &t5500, Value: "words"}, + })) + Expect(lyrics.Line[2].Value).To(Equal("More words")) + }) }) diff --git a/server/subsonic/helpers.go b/server/subsonic/helpers.go index 056ca89f1..b881fa169 100644 --- a/server/subsonic/helpers.go +++ b/server/subsonic/helpers.go @@ -515,16 +515,15 @@ func buildStructuredLyric(mf *model.MediaFile, lyrics model.Lyrics, enhanced boo roleOrder := make([]string, 0, 2) cuesByRole := make(map[string][]responses.LyricCue) for _, cue := range line.Cue { + if cue.Start == nil { + continue + } role := sanitizeRole(cue.Role) if _, exists := cuesByRole[role]; !exists { roleOrder = append(roleOrder, role) } - var start int64 - if cue.Start != nil { - start = *cue.Start - } cuesByRole[role] = append(cuesByRole[role], responses.LyricCue{ - Start: start, + Start: *cue.Start, End: cue.End, Value: cue.Value, }) @@ -552,15 +551,9 @@ func buildStructuredLyric(mf *model.MediaFile, lyrics model.Lyrics, enhanced boo } } - kind := strings.TrimSpace(lyrics.Kind) - if kind == "" { - kind = "main" - } - structured := responses.StructuredLyric{ DisplayArtist: lyrics.DisplayArtist, DisplayTitle: lyrics.DisplayTitle, - Kind: kind, Lang: lyrics.Lang, Line: lines, CueLine: cueLines, @@ -568,6 +561,14 @@ func buildStructuredLyric(mf *model.MediaFile, lyrics model.Lyrics, enhanced boo Synced: lyrics.Synced, } + if enhanced { + kind := strings.TrimSpace(lyrics.Kind) + if kind == "" { + kind = "main" + } + structured.Kind = kind + } + if structured.DisplayArtist == "" { structured.DisplayArtist = mf.Artist } diff --git a/server/subsonic/media_retrieval_test.go b/server/subsonic/media_retrieval_test.go index fa3f20e2d..0fdbb3854 100644 --- a/server/subsonic/media_retrieval_test.go +++ b/server/subsonic/media_retrieval_test.go @@ -232,11 +232,7 @@ var _ = Describe("MediaRetrievalController", func() { Expect(realLyric.DisplayArtist).To(Equal(expectedLyric.DisplayArtist)) Expect(realLyric.DisplayTitle).To(Equal(expectedLyric.DisplayTitle)) - expectedKind := expectedLyric.Kind - if expectedKind == "" { - expectedKind = "main" - } - Expect(realLyric.Kind).To(Equal(expectedKind)) + Expect(realLyric.Kind).To(Equal(expectedLyric.Kind)) Expect(realLyric.Lang).To(Equal(expectedLyric.Lang)) Expect(realLyric.Synced).To(Equal(expectedLyric.Synced)) @@ -587,6 +583,7 @@ var _ = Describe("MediaRetrievalController", func() { { DisplayArtist: "Rick Astley", DisplayTitle: "Never Gonna Give You Up", + Kind: "main", Lang: "eng", Synced: true, Line: []responses.Line{ diff --git a/server/subsonic/responses/responses.go b/server/subsonic/responses/responses.go index d74c118b3..f5446a961 100644 --- a/server/subsonic/responses/responses.go +++ b/server/subsonic/responses/responses.go @@ -540,7 +540,7 @@ type Line struct { type LyricCue struct { Start int64 `xml:"start,attr" json:"start"` End *int64 `xml:"end,attr,omitempty" json:"end,omitempty"` - Value string `xml:"value,attr" json:"value"` + Value string `xml:",chardata" json:"value"` } type CueLine struct { diff --git a/tests/fixtures/test-enhanced.lrc b/tests/fixtures/test-enhanced.lrc new file mode 100644 index 000000000..8f7b60f8c --- /dev/null +++ b/tests/fixtures/test-enhanced.lrc @@ -0,0 +1,6 @@ +[ar:Test Artist] +[ti:Enhanced Test] +[lang:eng] +[00:01.00]<00:01.00>Some <00:01.50>lyrics <00:02.00>here +[00:03.00]<00:03.00>More <00:03.50>words +[00:05.00]Plain line without inline markers diff --git a/ui/src/audioplayer/KaraokeLyricsOverlay.jsx b/ui/src/audioplayer/KaraokeLyricsOverlay.jsx index 3814cbee6..a44e50bf6 100644 --- a/ui/src/audioplayer/KaraokeLyricsOverlay.jsx +++ b/ui/src/audioplayer/KaraokeLyricsOverlay.jsx @@ -1,3 +1,12 @@ +import Button from '@material-ui/core/Button' +import IconButton from '@material-ui/core/IconButton' +import Popover from '@material-ui/core/Popover' +import Slider from '@material-ui/core/Slider' +import { makeStyles } from '@material-ui/core/styles' +import Typography from '@material-ui/core/Typography' +import CloseIcon from '@material-ui/icons/Close' +import TuneIcon from '@material-ui/icons/Tune' +import clsx from 'clsx' import React, { memo, useCallback, @@ -6,21 +15,12 @@ import React, { useRef, useState, } from 'react' -import clsx from 'clsx' -import Button from '@material-ui/core/Button' -import IconButton from '@material-ui/core/IconButton' -import Popover from '@material-ui/core/Popover' -import Slider from '@material-ui/core/Slider' -import Typography from '@material-ui/core/Typography' -import CloseIcon from '@material-ui/icons/Close' -import TuneIcon from '@material-ui/icons/Tune' -import { makeStyles } from '@material-ui/core/styles' import { buildKaraokeLines, getActiveKaraokeState, hasStructuredLyricContent, - resolveLayerLineForMain, resolveKaraokeTokenWindow, + resolveLayerLineForMain, } from './lyrics' const KARAOKE_RENDER_LEAD_MS = 24 @@ -421,9 +421,7 @@ const LyricsSettingsPopover = ({ settings, onChange }) => { const easeInOut = (v) => { const clamped = clamp(v, 0, 1) - return clamped < 0.5 - ? 2 * clamped * clamped - : 1 - Math.pow(-2 * clamped + 2, 2) / 2 + return clamped < 0.5 ? 2 * clamped * clamped : 1 - (-2 * clamped + 2) ** 2 / 2 } const getMaxHeightPx = () => { @@ -716,17 +714,23 @@ const KaraokeLineRow = memo( } alpha = clamp(alpha, TOKEN_FUTURE_ALPHA, TOKEN_ACTIVE_ALPHA) const fillProgress = isDone ? 1 : isActive ? progress : 0 + const isBgRole = segment.token?.role === 'bg' return ( {segment.text} @@ -1066,7 +1070,7 @@ const KaraokeLyricsOverlay = ({ const isActive = delta === 0 let opacity = isActive ? 1 : delta < 0 ? 0.6 : 0.72 const [r, g, b] = parseColorRGB(getColorValue(lyricsSettings.main.colorKey)) - let color = isActive + const color = isActive ? `rgba(${r}, ${g}, ${b}, 0.98)` : delta < 0 ? `rgba(${r}, ${g}, ${b}, 0.4)`