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 - z 0 - 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