lyrics-transcriber 0.34.2__py3-none-any.whl → 0.35.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 +10 -1
- lyrics_transcriber/correction/corrector.py +4 -3
- lyrics_transcriber/frontend/dist/assets/index-CQCER5Fo.js +181 -0
- lyrics_transcriber/frontend/dist/index.html +1 -1
- lyrics_transcriber/frontend/src/App.tsx +6 -2
- lyrics_transcriber/frontend/src/api.ts +9 -0
- lyrics_transcriber/frontend/src/components/AudioPlayer.tsx +155 -0
- lyrics_transcriber/frontend/src/components/CorrectionMetrics.tsx +1 -1
- lyrics_transcriber/frontend/src/components/DetailsModal.tsx +23 -191
- lyrics_transcriber/frontend/src/components/EditModal.tsx +407 -0
- lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +255 -221
- lyrics_transcriber/frontend/src/components/ModeSelector.tsx +39 -0
- lyrics_transcriber/frontend/src/components/ReferenceView.tsx +35 -264
- lyrics_transcriber/frontend/src/components/ReviewChangesModal.tsx +232 -0
- lyrics_transcriber/frontend/src/components/SegmentDetailsModal.tsx +64 -0
- lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +315 -0
- lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +116 -138
- lyrics_transcriber/frontend/src/components/WordEditControls.tsx +116 -0
- lyrics_transcriber/frontend/src/components/shared/components/HighlightedText.tsx +243 -0
- lyrics_transcriber/frontend/src/components/shared/components/SourceSelector.tsx +28 -0
- lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +52 -0
- lyrics_transcriber/frontend/src/components/{constants.ts → shared/constants.ts} +1 -0
- lyrics_transcriber/frontend/src/components/shared/hooks/useWordClick.ts +137 -0
- lyrics_transcriber/frontend/src/components/{styles.ts → shared/styles.ts} +1 -1
- lyrics_transcriber/frontend/src/components/shared/types.ts +99 -0
- lyrics_transcriber/frontend/src/components/shared/utils/newlineCalculator.ts +37 -0
- lyrics_transcriber/frontend/src/components/shared/utils/referenceLineCalculator.ts +76 -0
- lyrics_transcriber/frontend/src/types.ts +2 -43
- lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
- lyrics_transcriber/lyrics/spotify.py +11 -0
- lyrics_transcriber/output/generator.py +28 -11
- lyrics_transcriber/review/server.py +38 -12
- {lyrics_transcriber-0.34.2.dist-info → lyrics_transcriber-0.35.0.dist-info}/METADATA +1 -1
- {lyrics_transcriber-0.34.2.dist-info → lyrics_transcriber-0.35.0.dist-info}/RECORD +37 -24
- lyrics_transcriber/frontend/dist/assets/index-DqFgiUni.js +0 -245
- lyrics_transcriber/frontend/src/components/DebugPanel.tsx +0 -311
- {lyrics_transcriber-0.34.2.dist-info → lyrics_transcriber-0.35.0.dist-info}/LICENSE +0 -0
- {lyrics_transcriber-0.34.2.dist-info → lyrics_transcriber-0.35.0.dist-info}/WHEEL +0 -0
- {lyrics_transcriber-0.34.2.dist-info → lyrics_transcriber-0.35.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,407 @@
|
|
1
|
+
import {
|
2
|
+
Dialog,
|
3
|
+
DialogTitle,
|
4
|
+
DialogContent,
|
5
|
+
DialogActions,
|
6
|
+
IconButton,
|
7
|
+
Box,
|
8
|
+
TextField,
|
9
|
+
Button,
|
10
|
+
Typography,
|
11
|
+
Menu,
|
12
|
+
MenuItem,
|
13
|
+
} from '@mui/material'
|
14
|
+
import CloseIcon from '@mui/icons-material/Close'
|
15
|
+
import AddIcon from '@mui/icons-material/Add'
|
16
|
+
import DeleteIcon from '@mui/icons-material/Delete'
|
17
|
+
import MergeIcon from '@mui/icons-material/CallMerge'
|
18
|
+
import SplitIcon from '@mui/icons-material/CallSplit'
|
19
|
+
import RestoreIcon from '@mui/icons-material/RestoreFromTrash'
|
20
|
+
import MoreVertIcon from '@mui/icons-material/MoreVert'
|
21
|
+
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh'
|
22
|
+
import PlayCircleOutlineIcon from '@mui/icons-material/PlayCircleOutline'
|
23
|
+
import { LyricsSegment, Word } from '../types'
|
24
|
+
import { useState, useEffect } from 'react'
|
25
|
+
import TimelineEditor from './TimelineEditor'
|
26
|
+
|
27
|
+
interface EditModalProps {
|
28
|
+
open: boolean
|
29
|
+
onClose: () => void
|
30
|
+
segment: LyricsSegment | null
|
31
|
+
segmentIndex: number | null
|
32
|
+
originalSegment: LyricsSegment | null
|
33
|
+
onSave: (updatedSegment: LyricsSegment) => void
|
34
|
+
onPlaySegment?: (startTime: number) => void
|
35
|
+
currentTime?: number
|
36
|
+
onDelete?: (segmentIndex: number) => void
|
37
|
+
}
|
38
|
+
|
39
|
+
export default function EditModal({
|
40
|
+
open,
|
41
|
+
onClose,
|
42
|
+
segment,
|
43
|
+
segmentIndex,
|
44
|
+
originalSegment,
|
45
|
+
onSave,
|
46
|
+
onPlaySegment,
|
47
|
+
currentTime = 0,
|
48
|
+
onDelete,
|
49
|
+
}: EditModalProps) {
|
50
|
+
const [editedSegment, setEditedSegment] = useState<LyricsSegment | null>(segment)
|
51
|
+
const [menuAnchorEl, setMenuAnchorEl] = useState<null | HTMLElement>(null)
|
52
|
+
const [selectedWordIndex, setSelectedWordIndex] = useState<number | null>(null)
|
53
|
+
const [replacementText, setReplacementText] = useState('')
|
54
|
+
|
55
|
+
// Reset edited segment when modal opens with new segment
|
56
|
+
useEffect(() => {
|
57
|
+
setEditedSegment(segment)
|
58
|
+
}, [segment])
|
59
|
+
|
60
|
+
if (!segment || segmentIndex === null || !editedSegment || !originalSegment) return null
|
61
|
+
|
62
|
+
const handleWordChange = (index: number, field: keyof Word, value: string | number) => {
|
63
|
+
const newWords = [...editedSegment.words]
|
64
|
+
newWords[index] = {
|
65
|
+
...newWords[index],
|
66
|
+
[field]: field === 'start_time' || field === 'end_time'
|
67
|
+
? parseFloat(Number(value).toFixed(4))
|
68
|
+
: value
|
69
|
+
}
|
70
|
+
updateSegment(newWords)
|
71
|
+
}
|
72
|
+
|
73
|
+
const updateSegment = (newWords: Word[]) => {
|
74
|
+
const segmentStartTime = Math.min(...newWords.map(w => w.start_time))
|
75
|
+
const segmentEndTime = Math.max(...newWords.map(w => w.end_time))
|
76
|
+
|
77
|
+
setEditedSegment({
|
78
|
+
...editedSegment,
|
79
|
+
words: newWords,
|
80
|
+
text: newWords.map(w => w.text).join(' '),
|
81
|
+
start_time: segmentStartTime,
|
82
|
+
end_time: segmentEndTime
|
83
|
+
})
|
84
|
+
}
|
85
|
+
|
86
|
+
const handleAddWord = (index?: number) => {
|
87
|
+
const newWords = [...editedSegment.words]
|
88
|
+
let newWord: Word
|
89
|
+
|
90
|
+
if (index === undefined) {
|
91
|
+
// Add at end
|
92
|
+
const lastWord = newWords[newWords.length - 1]
|
93
|
+
newWord = {
|
94
|
+
text: '',
|
95
|
+
start_time: lastWord.end_time,
|
96
|
+
end_time: lastWord.end_time + 0.5,
|
97
|
+
confidence: 1.0
|
98
|
+
}
|
99
|
+
newWords.push(newWord)
|
100
|
+
} else {
|
101
|
+
// Add between words
|
102
|
+
const prevWord = newWords[index]
|
103
|
+
const nextWord = newWords[index + 1]
|
104
|
+
const midTime = prevWord ?
|
105
|
+
(nextWord ? (prevWord.end_time + nextWord.start_time) / 2 : prevWord.end_time + 0.5) :
|
106
|
+
(nextWord ? nextWord.start_time - 0.5 : 0)
|
107
|
+
|
108
|
+
newWord = {
|
109
|
+
text: '',
|
110
|
+
start_time: midTime - 0.25,
|
111
|
+
end_time: midTime + 0.25,
|
112
|
+
confidence: 1.0
|
113
|
+
}
|
114
|
+
newWords.splice(index + 1, 0, newWord)
|
115
|
+
}
|
116
|
+
|
117
|
+
updateSegment(newWords)
|
118
|
+
}
|
119
|
+
|
120
|
+
const handleSplitWord = (index: number) => {
|
121
|
+
const word = editedSegment.words[index]
|
122
|
+
const midTime = (word.start_time + word.end_time) / 2
|
123
|
+
const words = word.text.split(/\s+/)
|
124
|
+
|
125
|
+
if (words.length <= 1) {
|
126
|
+
// Split single word in half
|
127
|
+
const firstHalf = word.text.slice(0, Math.ceil(word.text.length / 2))
|
128
|
+
const secondHalf = word.text.slice(Math.ceil(word.text.length / 2))
|
129
|
+
words[0] = firstHalf
|
130
|
+
words[1] = secondHalf
|
131
|
+
}
|
132
|
+
|
133
|
+
const newWords = [...editedSegment.words]
|
134
|
+
newWords.splice(index, 1,
|
135
|
+
{
|
136
|
+
text: words[0],
|
137
|
+
start_time: word.start_time,
|
138
|
+
end_time: midTime,
|
139
|
+
confidence: 1.0
|
140
|
+
},
|
141
|
+
{
|
142
|
+
text: words[1],
|
143
|
+
start_time: midTime,
|
144
|
+
end_time: word.end_time,
|
145
|
+
confidence: 1.0
|
146
|
+
}
|
147
|
+
)
|
148
|
+
|
149
|
+
updateSegment(newWords)
|
150
|
+
}
|
151
|
+
|
152
|
+
const handleMergeWords = (index: number) => {
|
153
|
+
if (index >= editedSegment.words.length - 1) return
|
154
|
+
|
155
|
+
const word1 = editedSegment.words[index]
|
156
|
+
const word2 = editedSegment.words[index + 1]
|
157
|
+
const newWords = [...editedSegment.words]
|
158
|
+
|
159
|
+
newWords.splice(index, 2, {
|
160
|
+
text: `${word1.text} ${word2.text}`.trim(),
|
161
|
+
start_time: word1.start_time,
|
162
|
+
end_time: word2.end_time,
|
163
|
+
confidence: 1.0
|
164
|
+
})
|
165
|
+
|
166
|
+
updateSegment(newWords)
|
167
|
+
}
|
168
|
+
|
169
|
+
const handleRemoveWord = (index: number) => {
|
170
|
+
const newWords = editedSegment.words.filter((_, i) => i !== index)
|
171
|
+
updateSegment(newWords)
|
172
|
+
}
|
173
|
+
|
174
|
+
const handleReset = () => {
|
175
|
+
setEditedSegment(JSON.parse(JSON.stringify(originalSegment)))
|
176
|
+
}
|
177
|
+
|
178
|
+
const handleWordMenu = (event: React.MouseEvent<HTMLElement>, index: number) => {
|
179
|
+
setMenuAnchorEl(event.currentTarget)
|
180
|
+
setSelectedWordIndex(index)
|
181
|
+
}
|
182
|
+
|
183
|
+
const handleMenuClose = () => {
|
184
|
+
setMenuAnchorEl(null)
|
185
|
+
setSelectedWordIndex(null)
|
186
|
+
}
|
187
|
+
|
188
|
+
const handleSave = () => {
|
189
|
+
if (editedSegment) {
|
190
|
+
console.log('EditModal - Saving segment:', {
|
191
|
+
segmentIndex,
|
192
|
+
originalText: segment?.text,
|
193
|
+
editedText: editedSegment.text,
|
194
|
+
wordCount: editedSegment.words.length,
|
195
|
+
timeRange: `${editedSegment.start_time.toFixed(4)} - ${editedSegment.end_time.toFixed(4)}`
|
196
|
+
})
|
197
|
+
onSave(editedSegment)
|
198
|
+
onClose()
|
199
|
+
}
|
200
|
+
}
|
201
|
+
|
202
|
+
const handleReplaceAllWords = () => {
|
203
|
+
if (!editedSegment) return
|
204
|
+
|
205
|
+
const newWords = replacementText.trim().split(/\s+/)
|
206
|
+
const segmentDuration = editedSegment.end_time - editedSegment.start_time
|
207
|
+
|
208
|
+
let updatedWords: Word[]
|
209
|
+
|
210
|
+
if (newWords.length === editedSegment.words.length) {
|
211
|
+
// If word count matches, keep original timestamps
|
212
|
+
updatedWords = editedSegment.words.map((word, index) => ({
|
213
|
+
text: newWords[index],
|
214
|
+
start_time: word.start_time,
|
215
|
+
end_time: word.end_time,
|
216
|
+
confidence: 1.0
|
217
|
+
}))
|
218
|
+
} else {
|
219
|
+
// If word count differs, distribute time evenly
|
220
|
+
const avgWordDuration = segmentDuration / newWords.length
|
221
|
+
updatedWords = newWords.map((text, index) => ({
|
222
|
+
text,
|
223
|
+
start_time: editedSegment.start_time + (index * avgWordDuration),
|
224
|
+
end_time: editedSegment.start_time + ((index + 1) * avgWordDuration),
|
225
|
+
confidence: 1.0
|
226
|
+
}))
|
227
|
+
}
|
228
|
+
|
229
|
+
updateSegment(updatedWords)
|
230
|
+
setReplacementText('') // Clear the input after replacing
|
231
|
+
}
|
232
|
+
|
233
|
+
const handleKeyDown = (event: React.KeyboardEvent) => {
|
234
|
+
if (event.key === 'Enter' && !event.shiftKey) {
|
235
|
+
event.preventDefault()
|
236
|
+
handleSave()
|
237
|
+
}
|
238
|
+
}
|
239
|
+
|
240
|
+
const handleDelete = () => {
|
241
|
+
if (segmentIndex !== null && window.confirm('Are you sure you want to delete this segment?')) {
|
242
|
+
onDelete?.(segmentIndex)
|
243
|
+
onClose()
|
244
|
+
}
|
245
|
+
}
|
246
|
+
|
247
|
+
return (
|
248
|
+
<Dialog
|
249
|
+
open={open}
|
250
|
+
onClose={onClose}
|
251
|
+
maxWidth="md"
|
252
|
+
fullWidth
|
253
|
+
onKeyDown={handleKeyDown}
|
254
|
+
>
|
255
|
+
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
256
|
+
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
|
257
|
+
Edit Segment {segmentIndex}
|
258
|
+
{segment?.start_time !== undefined && onPlaySegment && (
|
259
|
+
<IconButton
|
260
|
+
size="small"
|
261
|
+
onClick={() => onPlaySegment(segment.start_time)}
|
262
|
+
sx={{ padding: '4px' }}
|
263
|
+
>
|
264
|
+
<PlayCircleOutlineIcon />
|
265
|
+
</IconButton>
|
266
|
+
)}
|
267
|
+
</Box>
|
268
|
+
<IconButton onClick={onClose} sx={{ ml: 'auto' }}>
|
269
|
+
<CloseIcon />
|
270
|
+
</IconButton>
|
271
|
+
</DialogTitle>
|
272
|
+
<DialogContent dividers>
|
273
|
+
<Box sx={{ mb: 2 }}>
|
274
|
+
<TimelineEditor
|
275
|
+
words={editedSegment.words}
|
276
|
+
startTime={editedSegment.start_time}
|
277
|
+
endTime={editedSegment.end_time}
|
278
|
+
onWordUpdate={(index, updates) => {
|
279
|
+
const newWords = [...editedSegment.words]
|
280
|
+
newWords[index] = { ...newWords[index], ...updates }
|
281
|
+
updateSegment(newWords)
|
282
|
+
}}
|
283
|
+
currentTime={currentTime}
|
284
|
+
/>
|
285
|
+
</Box>
|
286
|
+
|
287
|
+
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
288
|
+
Original Time Range: {originalSegment.start_time.toFixed(2)} - {originalSegment.end_time.toFixed(2)}
|
289
|
+
<br />
|
290
|
+
Current Time Range: {editedSegment.start_time.toFixed(2)} - {editedSegment.end_time.toFixed(2)}
|
291
|
+
</Typography>
|
292
|
+
|
293
|
+
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mb: 3 }}>
|
294
|
+
{editedSegment.words.map((word, index) => (
|
295
|
+
<Box key={index} sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
|
296
|
+
<TextField
|
297
|
+
label={`Word ${index}`}
|
298
|
+
value={word.text}
|
299
|
+
onChange={(e) => handleWordChange(index, 'text', e.target.value)}
|
300
|
+
fullWidth
|
301
|
+
size="small"
|
302
|
+
/>
|
303
|
+
<TextField
|
304
|
+
label="Start Time"
|
305
|
+
value={word.start_time.toFixed(2)}
|
306
|
+
onChange={(e) => handleWordChange(index, 'start_time', parseFloat(e.target.value))}
|
307
|
+
type="number"
|
308
|
+
inputProps={{ step: 0.01 }}
|
309
|
+
sx={{ width: '150px' }}
|
310
|
+
size="small"
|
311
|
+
/>
|
312
|
+
<TextField
|
313
|
+
label="End Time"
|
314
|
+
value={word.end_time.toFixed(2)}
|
315
|
+
onChange={(e) => handleWordChange(index, 'end_time', parseFloat(e.target.value))}
|
316
|
+
type="number"
|
317
|
+
inputProps={{ step: 0.01 }}
|
318
|
+
sx={{ width: '150px' }}
|
319
|
+
size="small"
|
320
|
+
/>
|
321
|
+
<IconButton onClick={(e) => handleWordMenu(e, index)}>
|
322
|
+
<MoreVertIcon />
|
323
|
+
</IconButton>
|
324
|
+
</Box>
|
325
|
+
))}
|
326
|
+
</Box>
|
327
|
+
|
328
|
+
<Box sx={{ display: 'flex', gap: 2 }}>
|
329
|
+
<TextField
|
330
|
+
label="Replace all words"
|
331
|
+
value={replacementText}
|
332
|
+
onChange={(e) => setReplacementText(e.target.value)}
|
333
|
+
fullWidth
|
334
|
+
placeholder="Type or paste replacement words here"
|
335
|
+
size="small"
|
336
|
+
/>
|
337
|
+
<Button
|
338
|
+
variant="contained"
|
339
|
+
startIcon={<AutoFixHighIcon />}
|
340
|
+
onClick={handleReplaceAllWords}
|
341
|
+
disabled={!replacementText.trim()}
|
342
|
+
>
|
343
|
+
Replace All
|
344
|
+
</Button>
|
345
|
+
</Box>
|
346
|
+
</DialogContent>
|
347
|
+
<DialogActions>
|
348
|
+
<Button
|
349
|
+
startIcon={<RestoreIcon />}
|
350
|
+
onClick={handleReset}
|
351
|
+
color="warning"
|
352
|
+
>
|
353
|
+
Reset
|
354
|
+
</Button>
|
355
|
+
<Button
|
356
|
+
startIcon={<DeleteIcon />}
|
357
|
+
onClick={handleDelete}
|
358
|
+
color="error"
|
359
|
+
sx={{ mr: 'auto' }}
|
360
|
+
>
|
361
|
+
Delete Segment
|
362
|
+
</Button>
|
363
|
+
<Button onClick={onClose}>Cancel</Button>
|
364
|
+
<Button onClick={handleSave} variant="contained">
|
365
|
+
Save Changes
|
366
|
+
</Button>
|
367
|
+
</DialogActions>
|
368
|
+
|
369
|
+
<Menu
|
370
|
+
anchorEl={menuAnchorEl}
|
371
|
+
open={Boolean(menuAnchorEl)}
|
372
|
+
onClose={handleMenuClose}
|
373
|
+
>
|
374
|
+
<MenuItem onClick={() => {
|
375
|
+
handleAddWord(selectedWordIndex!)
|
376
|
+
handleMenuClose()
|
377
|
+
}}>
|
378
|
+
<AddIcon sx={{ mr: 1 }} /> Add Word After
|
379
|
+
</MenuItem>
|
380
|
+
<MenuItem onClick={() => {
|
381
|
+
handleSplitWord(selectedWordIndex!)
|
382
|
+
handleMenuClose()
|
383
|
+
}}>
|
384
|
+
<SplitIcon sx={{ mr: 1 }} /> Split Word
|
385
|
+
</MenuItem>
|
386
|
+
<MenuItem
|
387
|
+
onClick={() => {
|
388
|
+
handleMergeWords(selectedWordIndex!)
|
389
|
+
handleMenuClose()
|
390
|
+
}}
|
391
|
+
disabled={selectedWordIndex === editedSegment.words.length - 1}
|
392
|
+
>
|
393
|
+
<MergeIcon sx={{ mr: 1 }} /> Merge with Next
|
394
|
+
</MenuItem>
|
395
|
+
<MenuItem
|
396
|
+
onClick={() => {
|
397
|
+
handleRemoveWord(selectedWordIndex!)
|
398
|
+
handleMenuClose()
|
399
|
+
}}
|
400
|
+
disabled={editedSegment.words.length <= 1}
|
401
|
+
>
|
402
|
+
<DeleteIcon sx={{ mr: 1 }} color="error" /> Remove
|
403
|
+
</MenuItem>
|
404
|
+
</Menu>
|
405
|
+
</Dialog>
|
406
|
+
)
|
407
|
+
}
|