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.
Files changed (39) hide show
  1. lyrics_transcriber/core/controller.py +10 -1
  2. lyrics_transcriber/correction/corrector.py +4 -3
  3. lyrics_transcriber/frontend/dist/assets/index-CQCER5Fo.js +181 -0
  4. lyrics_transcriber/frontend/dist/index.html +1 -1
  5. lyrics_transcriber/frontend/src/App.tsx +6 -2
  6. lyrics_transcriber/frontend/src/api.ts +9 -0
  7. lyrics_transcriber/frontend/src/components/AudioPlayer.tsx +155 -0
  8. lyrics_transcriber/frontend/src/components/CorrectionMetrics.tsx +1 -1
  9. lyrics_transcriber/frontend/src/components/DetailsModal.tsx +23 -191
  10. lyrics_transcriber/frontend/src/components/EditModal.tsx +407 -0
  11. lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +255 -221
  12. lyrics_transcriber/frontend/src/components/ModeSelector.tsx +39 -0
  13. lyrics_transcriber/frontend/src/components/ReferenceView.tsx +35 -264
  14. lyrics_transcriber/frontend/src/components/ReviewChangesModal.tsx +232 -0
  15. lyrics_transcriber/frontend/src/components/SegmentDetailsModal.tsx +64 -0
  16. lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +315 -0
  17. lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +116 -138
  18. lyrics_transcriber/frontend/src/components/WordEditControls.tsx +116 -0
  19. lyrics_transcriber/frontend/src/components/shared/components/HighlightedText.tsx +243 -0
  20. lyrics_transcriber/frontend/src/components/shared/components/SourceSelector.tsx +28 -0
  21. lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +52 -0
  22. lyrics_transcriber/frontend/src/components/{constants.ts → shared/constants.ts} +1 -0
  23. lyrics_transcriber/frontend/src/components/shared/hooks/useWordClick.ts +137 -0
  24. lyrics_transcriber/frontend/src/components/{styles.ts → shared/styles.ts} +1 -1
  25. lyrics_transcriber/frontend/src/components/shared/types.ts +99 -0
  26. lyrics_transcriber/frontend/src/components/shared/utils/newlineCalculator.ts +37 -0
  27. lyrics_transcriber/frontend/src/components/shared/utils/referenceLineCalculator.ts +76 -0
  28. lyrics_transcriber/frontend/src/types.ts +2 -43
  29. lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
  30. lyrics_transcriber/lyrics/spotify.py +11 -0
  31. lyrics_transcriber/output/generator.py +28 -11
  32. lyrics_transcriber/review/server.py +38 -12
  33. {lyrics_transcriber-0.34.2.dist-info → lyrics_transcriber-0.35.0.dist-info}/METADATA +1 -1
  34. {lyrics_transcriber-0.34.2.dist-info → lyrics_transcriber-0.35.0.dist-info}/RECORD +37 -24
  35. lyrics_transcriber/frontend/dist/assets/index-DqFgiUni.js +0 -245
  36. lyrics_transcriber/frontend/src/components/DebugPanel.tsx +0 -311
  37. {lyrics_transcriber-0.34.2.dist-info → lyrics_transcriber-0.35.0.dist-info}/LICENSE +0 -0
  38. {lyrics_transcriber-0.34.2.dist-info → lyrics_transcriber-0.35.0.dist-info}/WHEEL +0 -0
  39. {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
+ }