lyrics-transcriber 0.43.1__py3-none-any.whl → 0.45.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lyrics_transcriber/core/controller.py +58 -24
- lyrics_transcriber/correction/anchor_sequence.py +22 -8
- lyrics_transcriber/correction/corrector.py +47 -3
- lyrics_transcriber/correction/handlers/llm.py +15 -12
- lyrics_transcriber/correction/handlers/llm_providers.py +60 -0
- lyrics_transcriber/frontend/.yarn/install-state.gz +0 -0
- lyrics_transcriber/frontend/dist/assets/{index-D0Gr3Ep7.js → index-ZCT0s9MG.js} +10174 -6197
- lyrics_transcriber/frontend/dist/assets/index-ZCT0s9MG.js.map +1 -0
- lyrics_transcriber/frontend/dist/index.html +1 -1
- lyrics_transcriber/frontend/src/App.tsx +5 -5
- lyrics_transcriber/frontend/src/api.ts +37 -0
- lyrics_transcriber/frontend/src/components/AddLyricsModal.tsx +114 -0
- lyrics_transcriber/frontend/src/components/AudioPlayer.tsx +14 -10
- lyrics_transcriber/frontend/src/components/CorrectionMetrics.tsx +62 -56
- lyrics_transcriber/frontend/src/components/EditActionBar.tsx +68 -0
- lyrics_transcriber/frontend/src/components/EditModal.tsx +467 -399
- lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +373 -0
- lyrics_transcriber/frontend/src/components/EditWordList.tsx +308 -0
- lyrics_transcriber/frontend/src/components/FindReplaceModal.tsx +467 -0
- lyrics_transcriber/frontend/src/components/Header.tsx +141 -101
- lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +569 -107
- lyrics_transcriber/frontend/src/components/ModeSelector.tsx +22 -13
- lyrics_transcriber/frontend/src/components/PreviewVideoSection.tsx +1 -0
- lyrics_transcriber/frontend/src/components/ReferenceView.tsx +29 -12
- lyrics_transcriber/frontend/src/components/ReviewChangesModal.tsx +21 -4
- lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +29 -15
- lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +36 -18
- lyrics_transcriber/frontend/src/components/WordDivider.tsx +187 -0
- lyrics_transcriber/frontend/src/components/shared/components/HighlightedText.tsx +89 -41
- lyrics_transcriber/frontend/src/components/shared/components/SourceSelector.tsx +9 -2
- lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +27 -3
- lyrics_transcriber/frontend/src/components/shared/types.ts +17 -2
- lyrics_transcriber/frontend/src/components/shared/utils/keyboardHandlers.ts +90 -19
- lyrics_transcriber/frontend/src/components/shared/utils/segmentOperations.ts +192 -0
- lyrics_transcriber/frontend/src/hooks/useManualSync.ts +267 -0
- lyrics_transcriber/frontend/src/main.tsx +7 -1
- lyrics_transcriber/frontend/src/theme.ts +177 -0
- lyrics_transcriber/frontend/src/types.ts +1 -1
- lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
- lyrics_transcriber/lyrics/base_lyrics_provider.py +2 -2
- lyrics_transcriber/lyrics/user_input_provider.py +44 -0
- lyrics_transcriber/output/generator.py +40 -12
- lyrics_transcriber/review/server.py +238 -8
- {lyrics_transcriber-0.43.1.dist-info → lyrics_transcriber-0.45.0.dist-info}/METADATA +3 -2
- {lyrics_transcriber-0.43.1.dist-info → lyrics_transcriber-0.45.0.dist-info}/RECORD +48 -40
- lyrics_transcriber/frontend/dist/assets/index-D0Gr3Ep7.js.map +0 -1
- lyrics_transcriber/frontend/src/components/DetailsModal.tsx +0 -252
- lyrics_transcriber/frontend/src/components/WordEditControls.tsx +0 -110
- {lyrics_transcriber-0.43.1.dist-info → lyrics_transcriber-0.45.0.dist-info}/LICENSE +0 -0
- {lyrics_transcriber-0.43.1.dist-info → lyrics_transcriber-0.45.0.dist-info}/WHEEL +0 -0
- {lyrics_transcriber-0.43.1.dist-info → lyrics_transcriber-0.45.0.dist-info}/entry_points.txt +0 -0
@@ -173,13 +173,28 @@ export function HighlightedText({
|
|
173
173
|
wordPos.type === 'anchor' ? wordPos.sequence as AnchorSequence : undefined,
|
174
174
|
wordPos.type === 'gap' ? wordPos.sequence as GapSequence : undefined
|
175
175
|
)}
|
176
|
+
correction={(() => {
|
177
|
+
const correction = corrections?.find(c =>
|
178
|
+
c.corrected_word_id === wordPos.word.id ||
|
179
|
+
c.word_id === wordPos.word.id
|
180
|
+
);
|
181
|
+
return correction ? {
|
182
|
+
originalWord: correction.original_word,
|
183
|
+
handler: correction.handler,
|
184
|
+
confidence: correction.confidence
|
185
|
+
} : null;
|
186
|
+
})()}
|
176
187
|
/>
|
177
188
|
{index < wordPositions.length - 1 && ' '}
|
178
189
|
</React.Fragment>
|
179
190
|
))
|
180
191
|
} else if (segments) {
|
181
192
|
return segments.map((segment) => (
|
182
|
-
<Box key={segment.id} sx={{
|
193
|
+
<Box key={segment.id} sx={{
|
194
|
+
display: 'flex',
|
195
|
+
alignItems: 'flex-start',
|
196
|
+
mb: 0
|
197
|
+
}}>
|
183
198
|
<Box sx={{ flex: 1 }}>
|
184
199
|
{segment.words.map((word, wordIndex) => {
|
185
200
|
const wordPos = wordPositions.find((pos: TranscriptionWordPosition) =>
|
@@ -195,6 +210,18 @@ export function HighlightedText({
|
|
195
210
|
|
196
211
|
const sequence = wordPos?.type === 'gap' ? wordPos.sequence as GapSequence : undefined;
|
197
212
|
|
213
|
+
// Find correction information for the tooltip
|
214
|
+
const correction = corrections?.find(c =>
|
215
|
+
c.corrected_word_id === word.id ||
|
216
|
+
c.word_id === word.id
|
217
|
+
);
|
218
|
+
|
219
|
+
const correctionInfo = correction ? {
|
220
|
+
originalWord: correction.original_word,
|
221
|
+
handler: correction.handler,
|
222
|
+
confidence: correction.confidence
|
223
|
+
} : null;
|
224
|
+
|
198
225
|
return (
|
199
226
|
<React.Fragment key={word.id}>
|
200
227
|
<WordComponent
|
@@ -205,6 +232,7 @@ export function HighlightedText({
|
|
205
232
|
isUncorrectedGap={isUncorrectedGap}
|
206
233
|
isCurrentlyPlaying={shouldHighlightWord(wordPos || { word: word.text, id: word.id })}
|
207
234
|
onClick={() => handleWordClick(word.text, word.id, anchor, sequence)}
|
235
|
+
correction={correctionInfo}
|
208
236
|
/>
|
209
237
|
{wordIndex < segment.words.length - 1 && ' '}
|
210
238
|
</React.Fragment>
|
@@ -222,7 +250,12 @@ export function HighlightedText({
|
|
222
250
|
if (currentLinePosition?.isEmpty) {
|
223
251
|
wordCount++
|
224
252
|
return (
|
225
|
-
<Box key={`empty-${lineIndex}`} sx={{
|
253
|
+
<Box key={`empty-${lineIndex}`} sx={{
|
254
|
+
display: 'flex',
|
255
|
+
alignItems: 'flex-start',
|
256
|
+
mb: 0,
|
257
|
+
lineHeight: 1
|
258
|
+
}}>
|
226
259
|
<Typography
|
227
260
|
component="span"
|
228
261
|
sx={{
|
@@ -233,20 +266,58 @@ export function HighlightedText({
|
|
233
266
|
marginRight: 1,
|
234
267
|
userSelect: 'none',
|
235
268
|
fontFamily: 'monospace',
|
236
|
-
paddingTop: '
|
269
|
+
paddingTop: '1px',
|
270
|
+
fontSize: '0.8rem',
|
271
|
+
lineHeight: 1
|
237
272
|
}}
|
238
273
|
>
|
239
274
|
{currentLinePosition.lineNumber}
|
240
275
|
</Typography>
|
241
|
-
<Box sx={{ width: '
|
242
|
-
<Box sx={{ flex: 1, height: '
|
276
|
+
<Box sx={{ width: '18px' }} />
|
277
|
+
<Box sx={{ flex: 1, height: '1em' }} />
|
243
278
|
</Box>
|
244
279
|
)
|
245
280
|
}
|
246
281
|
|
247
|
-
const
|
282
|
+
const words = line.split(' ')
|
283
|
+
const lineWords: React.ReactNode[] = []
|
284
|
+
|
285
|
+
words.forEach((word, wordIndex) => {
|
286
|
+
if (word === '') return null
|
287
|
+
if (/^\s+$/.test(word)) {
|
288
|
+
return lineWords.push(<span key={`space-${lineIndex}-${wordIndex}`}> </span>)
|
289
|
+
}
|
290
|
+
|
291
|
+
const wordId = `${currentSource}-word-${wordCount}`
|
292
|
+
wordCount++
|
293
|
+
|
294
|
+
const anchor = currentSource ? anchors?.find(a =>
|
295
|
+
a.reference_word_ids[currentSource]?.includes(wordId)
|
296
|
+
) : undefined
|
297
|
+
|
298
|
+
const hasCorrection = referenceCorrections.has(wordId)
|
299
|
+
|
300
|
+
lineWords.push(
|
301
|
+
<WordComponent
|
302
|
+
key={wordId}
|
303
|
+
word={word}
|
304
|
+
shouldFlash={shouldWordFlash({ word, id: wordId })}
|
305
|
+
isAnchor={Boolean(anchor)}
|
306
|
+
isCorrectedGap={hasCorrection}
|
307
|
+
isUncorrectedGap={false}
|
308
|
+
isCurrentlyPlaying={shouldHighlightWord({ word, id: wordId })}
|
309
|
+
onClick={() => handleWordClick(word, wordId, anchor, undefined)}
|
310
|
+
/>
|
311
|
+
)
|
312
|
+
})
|
313
|
+
|
248
314
|
return (
|
249
|
-
<Box key={`line-${lineIndex}`} sx={{
|
315
|
+
<Box key={`line-${lineIndex}`} sx={{
|
316
|
+
display: 'flex',
|
317
|
+
alignItems: 'flex-start',
|
318
|
+
mb: 0,
|
319
|
+
lineHeight: 1
|
320
|
+
}}>
|
250
321
|
<Typography
|
251
322
|
component="span"
|
252
323
|
sx={{
|
@@ -257,7 +328,9 @@ export function HighlightedText({
|
|
257
328
|
marginRight: 1,
|
258
329
|
userSelect: 'none',
|
259
330
|
fontFamily: 'monospace',
|
260
|
-
paddingTop: '
|
331
|
+
paddingTop: '1px',
|
332
|
+
fontSize: '0.8rem',
|
333
|
+
lineHeight: 1
|
261
334
|
}}
|
262
335
|
>
|
263
336
|
{currentLinePosition?.lineNumber ?? lineIndex}
|
@@ -266,43 +339,18 @@ export function HighlightedText({
|
|
266
339
|
size="small"
|
267
340
|
onClick={() => handleCopyLine(line)}
|
268
341
|
sx={{
|
269
|
-
padding: '
|
270
|
-
marginRight:
|
271
|
-
height: '
|
272
|
-
width: '
|
342
|
+
padding: '1px',
|
343
|
+
marginRight: 0.5,
|
344
|
+
height: '18px',
|
345
|
+
width: '18px',
|
346
|
+
minHeight: '18px',
|
347
|
+
minWidth: '18px'
|
273
348
|
}}
|
274
349
|
>
|
275
|
-
<ContentCopyIcon sx={{ fontSize: '
|
350
|
+
<ContentCopyIcon sx={{ fontSize: '0.9rem' }} />
|
276
351
|
</IconButton>
|
277
352
|
<Box sx={{ flex: 1 }}>
|
278
|
-
{
|
279
|
-
if (word === '') return null
|
280
|
-
if (/^\s+$/.test(word)) {
|
281
|
-
return <span key={`space-${lineIndex}-${wordIndex}`}> </span>
|
282
|
-
}
|
283
|
-
|
284
|
-
const wordId = `${currentSource}-word-${wordCount}`
|
285
|
-
wordCount++
|
286
|
-
|
287
|
-
const anchor = currentSource ? anchors?.find(a =>
|
288
|
-
a.reference_word_ids[currentSource]?.includes(wordId)
|
289
|
-
) : undefined
|
290
|
-
|
291
|
-
const hasCorrection = referenceCorrections.has(wordId)
|
292
|
-
|
293
|
-
return (
|
294
|
-
<WordComponent
|
295
|
-
key={wordId}
|
296
|
-
word={word}
|
297
|
-
shouldFlash={shouldWordFlash({ word, id: wordId })}
|
298
|
-
isAnchor={Boolean(anchor)}
|
299
|
-
isCorrectedGap={hasCorrection}
|
300
|
-
isUncorrectedGap={false}
|
301
|
-
isCurrentlyPlaying={shouldHighlightWord({ word, id: wordId })}
|
302
|
-
onClick={() => handleWordClick(word, wordId, anchor, undefined)}
|
303
|
-
/>
|
304
|
-
)
|
305
|
-
})}
|
353
|
+
{lineWords}
|
306
354
|
</Box>
|
307
355
|
</Box>
|
308
356
|
)
|
@@ -8,14 +8,21 @@ export interface SourceSelectorProps {
|
|
8
8
|
|
9
9
|
export function SourceSelector({ currentSource, onSourceChange, availableSources }: SourceSelectorProps) {
|
10
10
|
return (
|
11
|
-
<Box>
|
11
|
+
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.3 }}>
|
12
12
|
{availableSources.map((source) => (
|
13
13
|
<Button
|
14
14
|
key={source}
|
15
15
|
size="small"
|
16
16
|
variant={currentSource === source ? 'contained' : 'outlined'}
|
17
17
|
onClick={() => onSourceChange(source)}
|
18
|
-
sx={{
|
18
|
+
sx={{
|
19
|
+
mr: 0,
|
20
|
+
py: 0.2,
|
21
|
+
px: 0.8,
|
22
|
+
minWidth: 'auto',
|
23
|
+
fontSize: '0.7rem',
|
24
|
+
lineHeight: 1.2
|
25
|
+
}}
|
19
26
|
>
|
20
27
|
{/* Capitalize first letter of source */}
|
21
28
|
{source.charAt(0).toUpperCase() + source.slice(1)}
|
@@ -2,6 +2,7 @@ import React from 'react'
|
|
2
2
|
import { COLORS } from '../constants'
|
3
3
|
import { HighlightedWord } from '../styles'
|
4
4
|
import { WordProps } from '../types'
|
5
|
+
import { Tooltip } from '@mui/material'
|
5
6
|
|
6
7
|
export const WordComponent = React.memo(function Word({
|
7
8
|
word,
|
@@ -10,8 +11,9 @@ export const WordComponent = React.memo(function Word({
|
|
10
11
|
isCorrectedGap,
|
11
12
|
isUncorrectedGap,
|
12
13
|
isCurrentlyPlaying,
|
13
|
-
padding = '
|
14
|
+
padding = '1px 3px',
|
14
15
|
onClick,
|
16
|
+
correction
|
15
17
|
}: WordProps) {
|
16
18
|
if (/^\s+$/.test(word)) {
|
17
19
|
return word
|
@@ -29,15 +31,20 @@ export const WordComponent = React.memo(function Word({
|
|
29
31
|
? COLORS.uncorrectedGap
|
30
32
|
: 'transparent'
|
31
33
|
|
32
|
-
|
34
|
+
const wordElement = (
|
33
35
|
<HighlightedWord
|
34
36
|
shouldFlash={shouldFlash}
|
35
37
|
style={{
|
36
38
|
backgroundColor,
|
37
39
|
padding,
|
38
40
|
cursor: 'pointer',
|
39
|
-
borderRadius: '
|
41
|
+
borderRadius: '2px',
|
40
42
|
color: isCurrentlyPlaying ? '#ffffff' : 'inherit',
|
43
|
+
textDecoration: correction ? 'underline dotted' : 'none',
|
44
|
+
textDecorationColor: correction ? '#666' : 'inherit',
|
45
|
+
textUnderlineOffset: '2px',
|
46
|
+
fontSize: '0.85rem',
|
47
|
+
lineHeight: 1.2
|
41
48
|
}}
|
42
49
|
sx={{
|
43
50
|
'&:hover': {
|
@@ -49,4 +56,21 @@ export const WordComponent = React.memo(function Word({
|
|
49
56
|
{word}
|
50
57
|
</HighlightedWord>
|
51
58
|
)
|
59
|
+
|
60
|
+
if (correction) {
|
61
|
+
const tooltipContent = (
|
62
|
+
<>
|
63
|
+
<strong>Original:</strong> "{correction.originalWord}"<br />
|
64
|
+
<strong>Corrected by:</strong> {correction.handler}
|
65
|
+
</>
|
66
|
+
)
|
67
|
+
|
68
|
+
return (
|
69
|
+
<Tooltip title={tooltipContent} arrow placement="top">
|
70
|
+
{wordElement}
|
71
|
+
</Tooltip>
|
72
|
+
)
|
73
|
+
}
|
74
|
+
|
75
|
+
return wordElement
|
52
76
|
})
|
@@ -58,6 +58,11 @@ export interface WordProps {
|
|
58
58
|
isCurrentlyPlaying?: boolean
|
59
59
|
padding?: string
|
60
60
|
onClick?: () => void
|
61
|
+
correction?: {
|
62
|
+
originalWord: string
|
63
|
+
handler: string
|
64
|
+
confidence: number
|
65
|
+
} | null
|
61
66
|
}
|
62
67
|
|
63
68
|
// Text segment props
|
@@ -100,11 +105,21 @@ export interface ReferenceViewProps extends BaseViewProps {
|
|
100
105
|
// Update HighlightedTextProps to include linePositions
|
101
106
|
export interface HighlightedTextProps extends BaseViewProps {
|
102
107
|
text?: string
|
103
|
-
|
108
|
+
segments?: LyricsSegment[]
|
109
|
+
wordPositions: TranscriptionWordPosition[] | ReferenceWordPosition[]
|
104
110
|
anchors: AnchorSequence[]
|
105
|
-
|
111
|
+
highlightInfo: HighlightInfo | null
|
112
|
+
mode: InteractionMode
|
113
|
+
onElementClick: (content: ModalContent) => void
|
114
|
+
onWordClick?: (info: WordClickInfo) => void
|
115
|
+
flashingType: FlashType
|
106
116
|
isReference?: boolean
|
107
117
|
currentSource?: string
|
108
118
|
preserveSegments?: boolean
|
109
119
|
linePositions?: LinePosition[]
|
120
|
+
currentTime?: number
|
121
|
+
referenceCorrections?: Map<string, string>
|
122
|
+
gaps?: GapSequence[]
|
123
|
+
flashingHandler?: string | null
|
124
|
+
corrections?: WordCorrection[]
|
110
125
|
}
|
@@ -1,10 +1,11 @@
|
|
1
1
|
// Add a global ref for the modal handler
|
2
2
|
let currentModalHandler: ((e: KeyboardEvent) => void) | undefined
|
3
3
|
let isModalOpen = false
|
4
|
+
const debugLog = false
|
4
5
|
|
5
6
|
type KeyboardState = {
|
6
7
|
setIsShiftPressed: (value: boolean) => void
|
7
|
-
setIsCtrlPressed
|
8
|
+
setIsCtrlPressed?: (value: boolean) => void
|
8
9
|
modalHandler?: {
|
9
10
|
isOpen: boolean
|
10
11
|
onSpacebar?: (e: KeyboardEvent) => void
|
@@ -13,17 +14,45 @@ type KeyboardState = {
|
|
13
14
|
|
14
15
|
// Add functions to update the modal handler state
|
15
16
|
export const setModalHandler = (handler: ((e: KeyboardEvent) => void) | undefined, open: boolean) => {
|
17
|
+
if (debugLog) {
|
18
|
+
console.log('setModalHandler called', {
|
19
|
+
hasHandler: !!handler,
|
20
|
+
open,
|
21
|
+
previousState: {
|
22
|
+
hadHandler: !!currentModalHandler,
|
23
|
+
wasOpen: isModalOpen
|
24
|
+
}
|
25
|
+
})
|
26
|
+
}
|
27
|
+
|
16
28
|
currentModalHandler = handler
|
17
29
|
isModalOpen = open
|
18
30
|
}
|
19
31
|
|
20
32
|
export const setupKeyboardHandlers = (state: KeyboardState) => {
|
21
33
|
const handlerId = Math.random().toString(36).substr(2, 9)
|
22
|
-
|
34
|
+
if (debugLog) {
|
35
|
+
console.log(`Setting up keyboard handlers [${handlerId}]`)
|
36
|
+
}
|
23
37
|
|
24
38
|
const handleKeyDown = (e: KeyboardEvent) => {
|
39
|
+
if (debugLog) {
|
40
|
+
console.log(`Keyboard event captured [${handlerId}]`, {
|
41
|
+
key: e.key,
|
42
|
+
code: e.code,
|
43
|
+
type: e.type,
|
44
|
+
target: e.target,
|
45
|
+
currentTarget: e.currentTarget,
|
46
|
+
eventPhase: e.eventPhase,
|
47
|
+
isModalOpen,
|
48
|
+
hasModalHandler: !!currentModalHandler
|
49
|
+
})
|
50
|
+
}
|
51
|
+
|
25
52
|
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
26
|
-
|
53
|
+
if (debugLog) {
|
54
|
+
console.log(`[${handlerId}] Ignoring keydown in input/textarea`)
|
55
|
+
}
|
27
56
|
return
|
28
57
|
}
|
29
58
|
|
@@ -31,37 +60,79 @@ export const setupKeyboardHandlers = (state: KeyboardState) => {
|
|
31
60
|
state.setIsShiftPressed(true)
|
32
61
|
document.body.style.userSelect = 'none'
|
33
62
|
} else if (e.key === 'Meta') {
|
34
|
-
state.setIsCtrlPressed(true)
|
63
|
+
state.setIsCtrlPressed?.(true)
|
35
64
|
} else if (e.key === ' ' || e.code === 'Space') {
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
65
|
+
if (debugLog) {
|
66
|
+
console.log('Keyboard handler - Spacebar pressed down', {
|
67
|
+
modalOpen: isModalOpen,
|
68
|
+
hasModalHandler: !!currentModalHandler,
|
69
|
+
hasGlobalToggle: !!window.toggleAudioPlayback,
|
70
|
+
target: e.target,
|
71
|
+
eventPhase: e.eventPhase,
|
72
|
+
handlerFunction: currentModalHandler?.toString().slice(0, 100)
|
73
|
+
})
|
74
|
+
}
|
75
|
+
|
42
76
|
e.preventDefault()
|
43
|
-
|
44
|
-
// If modal is open and has a handler, use that
|
77
|
+
|
45
78
|
if (isModalOpen && currentModalHandler) {
|
46
|
-
|
79
|
+
if (debugLog) {
|
80
|
+
console.log('Keyboard handler - Delegating to modal handler')
|
81
|
+
}
|
47
82
|
currentModalHandler(e)
|
48
|
-
}
|
49
|
-
|
50
|
-
|
51
|
-
|
83
|
+
} else if (window.toggleAudioPlayback && !isModalOpen) {
|
84
|
+
if (debugLog) {
|
85
|
+
console.log('Keyboard handler - Using global audio toggle')
|
86
|
+
}
|
52
87
|
window.toggleAudioPlayback()
|
53
88
|
}
|
54
89
|
}
|
55
90
|
}
|
56
91
|
|
57
92
|
const handleKeyUp = (e: KeyboardEvent) => {
|
93
|
+
if (debugLog) {
|
94
|
+
console.log(`Keyboard up event captured [${handlerId}]`, {
|
95
|
+
key: e.key,
|
96
|
+
code: e.code,
|
97
|
+
type: e.type,
|
98
|
+
target: e.target,
|
99
|
+
eventPhase: e.eventPhase,
|
100
|
+
isModalOpen,
|
101
|
+
hasModalHandler: !!currentModalHandler
|
102
|
+
})
|
103
|
+
}
|
104
|
+
|
58
105
|
if (e.key === 'Shift') {
|
59
106
|
state.setIsShiftPressed(false)
|
60
107
|
document.body.style.userSelect = ''
|
61
108
|
} else if (e.key === 'Meta') {
|
62
|
-
state.setIsCtrlPressed(false)
|
109
|
+
state.setIsCtrlPressed?.(false)
|
110
|
+
} else if (e.key === ' ' || e.code === 'Space') {
|
111
|
+
if (debugLog) {
|
112
|
+
console.log('Keyboard handler - Spacebar released', {
|
113
|
+
modalOpen: isModalOpen,
|
114
|
+
hasModalHandler: !!currentModalHandler,
|
115
|
+
target: e.target,
|
116
|
+
eventPhase: e.eventPhase
|
117
|
+
})
|
118
|
+
}
|
119
|
+
|
120
|
+
e.preventDefault()
|
121
|
+
|
122
|
+
if (isModalOpen && currentModalHandler) {
|
123
|
+
if (debugLog) {
|
124
|
+
console.log('Keyboard handler - Delegating keyup to modal handler')
|
125
|
+
}
|
126
|
+
currentModalHandler(e)
|
127
|
+
}
|
63
128
|
}
|
64
129
|
}
|
65
130
|
|
66
131
|
return { handleKeyDown, handleKeyUp }
|
67
|
-
}
|
132
|
+
}
|
133
|
+
|
134
|
+
// Export these for external use
|
135
|
+
export const getModalState = () => ({
|
136
|
+
currentModalHandler,
|
137
|
+
isModalOpen
|
138
|
+
})
|
@@ -117,5 +117,197 @@ export const updateSegment = (
|
|
117
117
|
|
118
118
|
newData.corrected_segments[segmentIndex] = updatedSegment
|
119
119
|
|
120
|
+
return newData
|
121
|
+
}
|
122
|
+
|
123
|
+
export function mergeSegment(data: CorrectionData, segmentIndex: number, mergeWithNext: boolean): CorrectionData {
|
124
|
+
const segments = [...data.corrected_segments]
|
125
|
+
const targetIndex = mergeWithNext ? segmentIndex + 1 : segmentIndex - 1
|
126
|
+
|
127
|
+
// Check if target segment exists
|
128
|
+
if (targetIndex < 0 || targetIndex >= segments.length) {
|
129
|
+
return data
|
130
|
+
}
|
131
|
+
|
132
|
+
const baseSegment = segments[segmentIndex]
|
133
|
+
const targetSegment = segments[targetIndex]
|
134
|
+
|
135
|
+
// Create merged segment
|
136
|
+
const mergedSegment: LyricsSegment = {
|
137
|
+
id: nanoid(),
|
138
|
+
words: mergeWithNext
|
139
|
+
? [...baseSegment.words, ...targetSegment.words]
|
140
|
+
: [...targetSegment.words, ...baseSegment.words],
|
141
|
+
text: mergeWithNext
|
142
|
+
? `${baseSegment.text} ${targetSegment.text}`
|
143
|
+
: `${targetSegment.text} ${baseSegment.text}`,
|
144
|
+
start_time: Math.min(
|
145
|
+
baseSegment.start_time ?? Infinity,
|
146
|
+
targetSegment.start_time ?? Infinity
|
147
|
+
),
|
148
|
+
end_time: Math.max(
|
149
|
+
baseSegment.end_time ?? -Infinity,
|
150
|
+
targetSegment.end_time ?? -Infinity
|
151
|
+
)
|
152
|
+
}
|
153
|
+
|
154
|
+
// Replace the two segments with the merged one
|
155
|
+
const minIndex = Math.min(segmentIndex, targetIndex)
|
156
|
+
segments.splice(minIndex, 2, mergedSegment)
|
157
|
+
|
158
|
+
return {
|
159
|
+
...data,
|
160
|
+
corrected_segments: segments
|
161
|
+
}
|
162
|
+
}
|
163
|
+
|
164
|
+
export function findAndReplace(
|
165
|
+
data: CorrectionData,
|
166
|
+
findText: string,
|
167
|
+
replaceText: string,
|
168
|
+
options: { caseSensitive: boolean, useRegex: boolean, fullTextMode?: boolean } = {
|
169
|
+
caseSensitive: false,
|
170
|
+
useRegex: false,
|
171
|
+
fullTextMode: false
|
172
|
+
}
|
173
|
+
): CorrectionData {
|
174
|
+
const newData = { ...data }
|
175
|
+
|
176
|
+
// If full text mode is enabled, perform replacements across word boundaries
|
177
|
+
if (options.fullTextMode) {
|
178
|
+
newData.corrected_segments = data.corrected_segments.map(segment => {
|
179
|
+
// Create a pattern for the full segment text
|
180
|
+
let pattern: RegExp
|
181
|
+
|
182
|
+
if (options.useRegex) {
|
183
|
+
pattern = new RegExp(findText, options.caseSensitive ? 'g' : 'gi')
|
184
|
+
} else {
|
185
|
+
const escapedFindText = findText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
186
|
+
pattern = new RegExp(escapedFindText, options.caseSensitive ? 'g' : 'gi')
|
187
|
+
}
|
188
|
+
|
189
|
+
// Get the full segment text
|
190
|
+
const segmentText = segment.text
|
191
|
+
|
192
|
+
// If no matches, return the segment unchanged
|
193
|
+
if (!pattern.test(segmentText)) {
|
194
|
+
return segment
|
195
|
+
}
|
196
|
+
|
197
|
+
// Reset pattern for replacement
|
198
|
+
pattern.lastIndex = 0
|
199
|
+
|
200
|
+
// Replace in the full segment text
|
201
|
+
const newSegmentText = segmentText.replace(pattern, replaceText)
|
202
|
+
|
203
|
+
// Split the new text into words
|
204
|
+
const newWordTexts = newSegmentText.trim().split(/\s+/).filter(text => text.length > 0)
|
205
|
+
|
206
|
+
// Create new word objects
|
207
|
+
// We'll try to preserve original word IDs and timing info where possible
|
208
|
+
const newWords = []
|
209
|
+
|
210
|
+
// If we have the same number of words, we can preserve IDs and timing
|
211
|
+
if (newWordTexts.length === segment.words.length) {
|
212
|
+
for (let i = 0; i < newWordTexts.length; i++) {
|
213
|
+
newWords.push({
|
214
|
+
...segment.words[i],
|
215
|
+
text: newWordTexts[i]
|
216
|
+
})
|
217
|
+
}
|
218
|
+
}
|
219
|
+
// If we have fewer words than before, some words were removed
|
220
|
+
else if (newWordTexts.length < segment.words.length) {
|
221
|
+
// Try to map new words to old words
|
222
|
+
let oldWordIndex = 0
|
223
|
+
for (let i = 0; i < newWordTexts.length; i++) {
|
224
|
+
// Find the next non-empty old word
|
225
|
+
while (oldWordIndex < segment.words.length &&
|
226
|
+
segment.words[oldWordIndex].text.trim() === '') {
|
227
|
+
oldWordIndex++
|
228
|
+
}
|
229
|
+
|
230
|
+
if (oldWordIndex < segment.words.length) {
|
231
|
+
newWords.push({
|
232
|
+
...segment.words[oldWordIndex],
|
233
|
+
text: newWordTexts[i]
|
234
|
+
})
|
235
|
+
oldWordIndex++
|
236
|
+
} else {
|
237
|
+
// If we run out of old words, create new ones
|
238
|
+
newWords.push({
|
239
|
+
id: nanoid(),
|
240
|
+
text: newWordTexts[i],
|
241
|
+
start_time: null,
|
242
|
+
end_time: null
|
243
|
+
})
|
244
|
+
}
|
245
|
+
}
|
246
|
+
}
|
247
|
+
// If we have more words than before, some words were added
|
248
|
+
else {
|
249
|
+
// Try to preserve original words where possible
|
250
|
+
for (let i = 0; i < newWordTexts.length; i++) {
|
251
|
+
if (i < segment.words.length) {
|
252
|
+
newWords.push({
|
253
|
+
...segment.words[i],
|
254
|
+
text: newWordTexts[i]
|
255
|
+
})
|
256
|
+
} else {
|
257
|
+
// For new words, create new IDs
|
258
|
+
newWords.push({
|
259
|
+
id: nanoid(),
|
260
|
+
text: newWordTexts[i],
|
261
|
+
start_time: null,
|
262
|
+
end_time: null
|
263
|
+
})
|
264
|
+
}
|
265
|
+
}
|
266
|
+
}
|
267
|
+
|
268
|
+
return {
|
269
|
+
...segment,
|
270
|
+
words: newWords,
|
271
|
+
text: newSegmentText
|
272
|
+
}
|
273
|
+
})
|
274
|
+
}
|
275
|
+
// Word-level replacement (original implementation)
|
276
|
+
else {
|
277
|
+
newData.corrected_segments = data.corrected_segments.map(segment => {
|
278
|
+
// Replace in each word
|
279
|
+
let newWords = segment.words.map(word => {
|
280
|
+
let pattern: RegExp
|
281
|
+
|
282
|
+
if (options.useRegex) {
|
283
|
+
// Create regex with or without case sensitivity
|
284
|
+
pattern = new RegExp(findText, options.caseSensitive ? 'g' : 'gi')
|
285
|
+
} else {
|
286
|
+
// Escape special regex characters for literal search
|
287
|
+
const escapedFindText = findText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
288
|
+
pattern = new RegExp(escapedFindText, options.caseSensitive ? 'g' : 'gi')
|
289
|
+
}
|
290
|
+
|
291
|
+
return {
|
292
|
+
...word,
|
293
|
+
text: word.text.replace(pattern, replaceText)
|
294
|
+
}
|
295
|
+
});
|
296
|
+
|
297
|
+
// Filter out words that have become empty
|
298
|
+
newWords = newWords.filter(word => word.text.trim() !== '');
|
299
|
+
|
300
|
+
// Update segment text
|
301
|
+
return {
|
302
|
+
...segment,
|
303
|
+
words: newWords,
|
304
|
+
text: newWords.map(w => w.text).join(' ')
|
305
|
+
}
|
306
|
+
});
|
307
|
+
}
|
308
|
+
|
309
|
+
// Filter out segments that have no words left
|
310
|
+
newData.corrected_segments = newData.corrected_segments.filter(segment => segment.words.length > 0);
|
311
|
+
|
120
312
|
return newData
|
121
313
|
}
|