Skip to content

Commit 5172ba0

Browse files
committed
add: implement YouTube lyrics alignment and enhance lyrics loading logic
1 parent 852dbb4 commit 5172ba0

2 files changed

Lines changed: 270 additions & 15 deletions

File tree

src/managers/lyricsManager.js

Lines changed: 61 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import fs from 'node:fs/promises'
22
import path from 'node:path'
33
import { fileURLToPath } from 'node:url'
44
import { logger } from '../utils.js'
5+
import { alignLyrics } from '../utils/lyricsAligner.js'
56

67
let lyricRegistry
78
try {
@@ -131,35 +132,80 @@ export default class LyricsManager {
131132
const trackInfo = reliableTrackData.data?.info || reliableTrackData.data
132133
const sourceName = trackInfo?.sourceName
133134
const lyricsSource = this.lyricsSources.get(sourceName)
135+
const isYouTube = sourceName === 'youtube' || sourceName === 'ytmusic'
136+
137+
let youtubeCaptions = null
134138

135139
if (lyricsSource && !skipTrackSource) {
136-
const lyrics = await lyricsSource.getLyrics(trackInfo, language)
137-
if (lyrics && lyrics.loadType !== 'empty') {
138-
lyrics.data.provider = sourceName
139-
return lyrics
140+
if (isYouTube) {
141+
try {
142+
const result = await lyricsSource.getLyrics(trackInfo, language)
143+
if (result && result.loadType === 'lyrics') {
144+
youtubeCaptions = result
145+
}
146+
} catch (e) {
147+
logger(
148+
'warn',
149+
'Lyrics',
150+
`Failed to fetch YouTube captions for alignment: ${e.message}`
151+
)
152+
}
153+
} else {
154+
const lyrics = await lyricsSource.getLyrics(trackInfo, language)
155+
if (lyrics && lyrics.loadType !== 'empty') {
156+
lyrics.data.provider = sourceName
157+
return lyrics
158+
}
140159
}
141160
}
142161

143162
for (const [name, source] of this.lyricsSources) {
144-
if (name !== sourceName) {
145-
logger(
146-
'debug',
147-
'Lyrics',
148-
`Trying lyrics source ${name} for ${trackInfo?.title || 'Unknown Title'}.`
149-
)
150-
const lyrics = await source.getLyrics(trackInfo, language)
151-
if (lyrics && lyrics.loadType !== 'empty') {
152-
lyrics.data.provider = name
153-
return lyrics
163+
if (name === sourceName) continue
164+
165+
logger(
166+
'debug',
167+
'Lyrics',
168+
`Trying lyrics source ${name} for ${trackInfo?.title || 'Unknown Title'}.`
169+
)
170+
const lyrics = await source.getLyrics(trackInfo, language)
171+
172+
if (lyrics && lyrics.loadType !== 'empty') {
173+
if (isYouTube && youtubeCaptions && lyrics.data.synced) {
174+
try {
175+
logger(
176+
'debug',
177+
'Lyrics',
178+
`Aligning ${name} lyrics with YouTube timing...`
179+
)
180+
const alignedLines = alignLyrics(
181+
lyrics.data.lines,
182+
youtubeCaptions.data
183+
)
184+
lyrics.data.lines = alignedLines
185+
} catch (alignErr) {
186+
logger(
187+
'warn',
188+
'Lyrics',
189+
`Failed to align lyrics: ${alignErr.message}`
190+
)
191+
}
154192
}
193+
194+
lyrics.data.provider = name
195+
return lyrics
155196
}
156197
}
157198

199+
if (isYouTube && youtubeCaptions) {
200+
youtubeCaptions.data.provider = sourceName
201+
return youtubeCaptions
202+
}
203+
158204
logger(
159205
'debug',
160206
'Lyrics',
161207
`No lyrics found for ${trackInfo?.title || 'Unknown Track'}`
162208
)
163209
return { loadType: 'empty', data: {} }
164210
}
165-
}
211+
}

src/utils/lyricsAligner.js

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
function similarity(s1, s2) {
2+
const longer = s1.length > s2.length ? s1 : s2
3+
const shorter = s1.length > s2.length ? s2 : s1
4+
const longerLength = longer.length
5+
if (longerLength === 0) return 1.0
6+
return (
7+
(longerLength - editDistance(longer, shorter)) / parseFloat(longerLength)
8+
)
9+
}
10+
11+
function editDistance(s1, s2) {
12+
s1 = s1.toLowerCase()
13+
s2 = s2.toLowerCase()
14+
const costs = new Array()
15+
for (let i = 0; i <= s1.length; i++) {
16+
let lastValue = i
17+
for (let j = 0; j <= s2.length; j++) {
18+
if (i == 0) costs[j] = j
19+
else {
20+
if (j > 0) {
21+
let newValue = costs[j - 1]
22+
if (s1.charAt(i - 1) != s2.charAt(j - 1))
23+
newValue = Math.min(Math.min(newValue, lastValue), costs[j]) + 1
24+
costs[j - 1] = lastValue
25+
lastValue = newValue
26+
}
27+
}
28+
}
29+
if (i > 0) costs[s2.length] = lastValue
30+
}
31+
return costs[s2.length]
32+
}
33+
34+
function cleanWord(text) {
35+
if (!text) return ''
36+
return text
37+
.replace(/\[.*?\]/g, '')
38+
.replace(/\(.*?\)/g, '')
39+
.toLowerCase()
40+
.replace(/[^a-z0-9]/g, '')
41+
}
42+
43+
function flattenYouTubeLyrics(ytLyrics) {
44+
const words = []
45+
if (!ytLyrics?.lines) return words
46+
47+
for (const line of ytLyrics.lines) {
48+
if (line.text && /^\[.*\]$/.test(line.text.trim())) continue
49+
50+
if (line.words) {
51+
for (const w of line.words) {
52+
const clean = cleanWord(w.text)
53+
if (clean.length > 0) {
54+
words.push({
55+
text: clean,
56+
time: parseInt(w.timestamp || w.time || 0)
57+
})
58+
}
59+
}
60+
} else if (line.text) {
61+
const lineText = line.text.trim()
62+
const lineWords = lineText.split(/\s+/)
63+
const durationPerWord = (line.duration || 2000) / (lineWords.length || 1)
64+
65+
lineWords.forEach((w, i) => {
66+
const clean = cleanWord(w)
67+
if (clean.length > 0) {
68+
words.push({
69+
text: clean,
70+
time: parseInt(line.time) + i * durationPerWord
71+
})
72+
}
73+
})
74+
}
75+
}
76+
return words
77+
}
78+
79+
function getLineWords(text) {
80+
if (!text) return []
81+
return text.split(/\s+/).map(cleanWord).filter((w) => w.length > 0)
82+
}
83+
84+
function findBestSequenceMatch(
85+
targetWords,
86+
ytWords,
87+
startIndex,
88+
searchWindowEnd
89+
) {
90+
if (targetWords.length === 0) return null
91+
const keys = targetWords.slice(0, 5)
92+
if (keys.length === 0) return null
93+
94+
let bestMatch = null
95+
let maxScore = 0
96+
97+
for (let i = startIndex; i < ytWords.length; i++) {
98+
const yw = ytWords[i]
99+
if (yw.time > searchWindowEnd) break
100+
101+
if (similarity(keys[0], yw.text) > 0.75) {
102+
let matchCount = 1
103+
let checkLen = Math.min(keys.length, ytWords.length - i)
104+
105+
let ytOffset = 0
106+
for (let k = 1; k < checkLen; k++) {
107+
if (i + k + ytOffset < ytWords.length) {
108+
if (similarity(keys[k], ytWords[i + k + ytOffset].text) > 0.75) {
109+
matchCount++
110+
} else if (
111+
i + k + ytOffset + 1 < ytWords.length &&
112+
similarity(keys[k], ytWords[i + k + ytOffset + 1].text) > 0.75
113+
) {
114+
matchCount++
115+
ytOffset++
116+
}
117+
}
118+
}
119+
120+
const score = matchCount / keys.length
121+
122+
if (score > maxScore && score >= 0.7) {
123+
maxScore = score
124+
bestMatch = { index: i, time: yw.time, score }
125+
if (score === 1.0) break
126+
}
127+
}
128+
}
129+
130+
return bestMatch
131+
}
132+
133+
export function alignLyrics(hqLyrics, youtubeData) {
134+
if (!hqLyrics?.length || !youtubeData?.lines) return hqLyrics
135+
136+
const ytWords = flattenYouTubeLyrics(youtubeData)
137+
if (ytWords.length === 0) return hqLyrics
138+
139+
const alignedLines = []
140+
141+
let lastYtIndex = 0
142+
let currentOffset = 0
143+
let offsetInitialized = false
144+
145+
let pendingDeviation = null
146+
const MAX_JUMP_MS = 2500
147+
const SEARCH_LOOKAHEAD = 25000
148+
149+
for (let i = 0; i < hqLyrics.length; i++) {
150+
const line = hqLyrics[i]
151+
const words = getLineWords(line.text)
152+
153+
const predictedYtTime = line.time + currentOffset
154+
155+
const match = findBestSequenceMatch(
156+
words,
157+
ytWords,
158+
lastYtIndex,
159+
predictedYtTime + SEARCH_LOOKAHEAD
160+
)
161+
162+
let offsetToUse = currentOffset
163+
164+
if (match) {
165+
const instantOffset = match.time - line.time
166+
167+
if (!offsetInitialized) {
168+
currentOffset = instantOffset
169+
offsetToUse = currentOffset
170+
offsetInitialized = true
171+
} else {
172+
const diff = Math.abs(instantOffset - currentOffset)
173+
174+
if (diff > MAX_JUMP_MS) {
175+
if (pendingDeviation) {
176+
if (Math.abs(instantOffset - pendingDeviation.offset) < 1000) {
177+
currentOffset = instantOffset
178+
offsetToUse = currentOffset
179+
pendingDeviation = null
180+
} else {
181+
pendingDeviation = { offset: instantOffset, index: i }
182+
offsetToUse = currentOffset
183+
}
184+
} else {
185+
pendingDeviation = { offset: instantOffset, index: i }
186+
offsetToUse = currentOffset
187+
}
188+
} else {
189+
pendingDeviation = null
190+
currentOffset = currentOffset * 0.8 + instantOffset * 0.2
191+
offsetToUse = currentOffset
192+
}
193+
}
194+
195+
if (match.index > lastYtIndex) {
196+
lastYtIndex = match.index
197+
}
198+
} else {
199+
offsetToUse = currentOffset
200+
}
201+
202+
alignedLines.push({
203+
...line,
204+
time: Math.max(0, Math.round(line.time + offsetToUse - 50))
205+
})
206+
}
207+
208+
return alignedLines
209+
}

0 commit comments

Comments
 (0)