lyrics-transcriber 0.41.0__py3-none-any.whl → 0.42.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 (77) hide show
  1. lyrics_transcriber/core/controller.py +30 -52
  2. lyrics_transcriber/correction/anchor_sequence.py +325 -150
  3. lyrics_transcriber/correction/corrector.py +224 -107
  4. lyrics_transcriber/correction/handlers/base.py +28 -10
  5. lyrics_transcriber/correction/handlers/extend_anchor.py +47 -24
  6. lyrics_transcriber/correction/handlers/levenshtein.py +75 -33
  7. lyrics_transcriber/correction/handlers/llm.py +290 -0
  8. lyrics_transcriber/correction/handlers/no_space_punct_match.py +81 -36
  9. lyrics_transcriber/correction/handlers/relaxed_word_count_match.py +46 -26
  10. lyrics_transcriber/correction/handlers/repeat.py +28 -11
  11. lyrics_transcriber/correction/handlers/sound_alike.py +68 -32
  12. lyrics_transcriber/correction/handlers/syllables_match.py +80 -30
  13. lyrics_transcriber/correction/handlers/word_count_match.py +36 -19
  14. lyrics_transcriber/correction/handlers/word_operations.py +68 -22
  15. lyrics_transcriber/correction/text_utils.py +3 -7
  16. lyrics_transcriber/frontend/.yarn/install-state.gz +0 -0
  17. lyrics_transcriber/frontend/.yarn/releases/yarn-4.6.0.cjs +934 -0
  18. lyrics_transcriber/frontend/.yarnrc.yml +3 -0
  19. lyrics_transcriber/frontend/dist/assets/{index-DKnNJHRK.js → index-coH8y7gV.js} +16284 -9032
  20. lyrics_transcriber/frontend/dist/assets/index-coH8y7gV.js.map +1 -0
  21. lyrics_transcriber/frontend/dist/index.html +1 -1
  22. lyrics_transcriber/frontend/package.json +6 -2
  23. lyrics_transcriber/frontend/src/App.tsx +18 -2
  24. lyrics_transcriber/frontend/src/api.ts +103 -6
  25. lyrics_transcriber/frontend/src/components/AudioPlayer.tsx +7 -6
  26. lyrics_transcriber/frontend/src/components/DetailsModal.tsx +86 -59
  27. lyrics_transcriber/frontend/src/components/EditModal.tsx +93 -43
  28. lyrics_transcriber/frontend/src/components/FileUpload.tsx +2 -2
  29. lyrics_transcriber/frontend/src/components/Header.tsx +251 -0
  30. lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +303 -265
  31. lyrics_transcriber/frontend/src/components/PreviewVideoSection.tsx +117 -0
  32. lyrics_transcriber/frontend/src/components/ReferenceView.tsx +125 -40
  33. lyrics_transcriber/frontend/src/components/ReviewChangesModal.tsx +129 -115
  34. lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +59 -78
  35. lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +40 -16
  36. lyrics_transcriber/frontend/src/components/WordEditControls.tsx +4 -10
  37. lyrics_transcriber/frontend/src/components/shared/components/HighlightedText.tsx +137 -68
  38. lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +1 -1
  39. lyrics_transcriber/frontend/src/components/shared/hooks/useWordClick.ts +85 -115
  40. lyrics_transcriber/frontend/src/components/shared/types.js +2 -0
  41. lyrics_transcriber/frontend/src/components/shared/types.ts +15 -7
  42. lyrics_transcriber/frontend/src/components/shared/utils/keyboardHandlers.ts +35 -0
  43. lyrics_transcriber/frontend/src/components/shared/utils/localStorage.ts +78 -0
  44. lyrics_transcriber/frontend/src/components/shared/utils/referenceLineCalculator.ts +7 -7
  45. lyrics_transcriber/frontend/src/components/shared/utils/segmentOperations.ts +121 -0
  46. lyrics_transcriber/frontend/src/components/shared/utils/wordUtils.ts +22 -0
  47. lyrics_transcriber/frontend/src/types.js +2 -0
  48. lyrics_transcriber/frontend/src/types.ts +70 -49
  49. lyrics_transcriber/frontend/src/validation.ts +132 -0
  50. lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
  51. lyrics_transcriber/frontend/yarn.lock +3752 -0
  52. lyrics_transcriber/lyrics/base_lyrics_provider.py +75 -12
  53. lyrics_transcriber/lyrics/file_provider.py +6 -5
  54. lyrics_transcriber/lyrics/genius.py +5 -2
  55. lyrics_transcriber/lyrics/spotify.py +58 -21
  56. lyrics_transcriber/output/ass/config.py +16 -5
  57. lyrics_transcriber/output/cdg.py +1 -1
  58. lyrics_transcriber/output/generator.py +22 -8
  59. lyrics_transcriber/output/plain_text.py +15 -10
  60. lyrics_transcriber/output/segment_resizer.py +16 -3
  61. lyrics_transcriber/output/subtitles.py +27 -1
  62. lyrics_transcriber/output/video.py +107 -1
  63. lyrics_transcriber/review/__init__.py +0 -1
  64. lyrics_transcriber/review/server.py +337 -164
  65. lyrics_transcriber/transcribers/audioshake.py +3 -0
  66. lyrics_transcriber/transcribers/base_transcriber.py +11 -3
  67. lyrics_transcriber/transcribers/whisper.py +11 -1
  68. lyrics_transcriber/types.py +151 -105
  69. lyrics_transcriber/utils/word_utils.py +27 -0
  70. {lyrics_transcriber-0.41.0.dist-info → lyrics_transcriber-0.42.0.dist-info}/METADATA +3 -1
  71. {lyrics_transcriber-0.41.0.dist-info → lyrics_transcriber-0.42.0.dist-info}/RECORD +74 -61
  72. {lyrics_transcriber-0.41.0.dist-info → lyrics_transcriber-0.42.0.dist-info}/WHEEL +1 -1
  73. lyrics_transcriber/frontend/dist/assets/index-DKnNJHRK.js.map +0 -1
  74. lyrics_transcriber/frontend/package-lock.json +0 -4260
  75. lyrics_transcriber/frontend/src/components/shared/utils/initializeDataWithIds.tsx +0 -202
  76. {lyrics_transcriber-0.41.0.dist-info → lyrics_transcriber-0.42.0.dist-info}/LICENSE +0 -0
  77. {lyrics_transcriber-0.41.0.dist-info → lyrics_transcriber-0.42.0.dist-info}/entry_points.txt +0 -0
@@ -35,6 +35,8 @@ interface EditModalProps {
35
35
  onPlaySegment?: (startTime: number) => void
36
36
  currentTime?: number
37
37
  onDelete?: (segmentIndex: number) => void
38
+ onAddSegment?: (segmentIndex: number) => void
39
+ onSplitSegment?: (segmentIndex: number, afterWordIndex: number) => void
38
40
  }
39
41
 
40
42
  export default function EditModal({
@@ -47,6 +49,8 @@ export default function EditModal({
47
49
  onPlaySegment,
48
50
  currentTime = 0,
49
51
  onDelete,
52
+ onAddSegment,
53
+ onSplitSegment,
50
54
  }: EditModalProps) {
51
55
  const [editedSegment, setEditedSegment] = useState<LyricsSegment | null>(segment)
52
56
  const [menuAnchorEl, setMenuAnchorEl] = useState<null | HTMLElement>(null)
@@ -58,22 +62,36 @@ export default function EditModal({
58
62
  setEditedSegment(segment)
59
63
  }, [segment])
60
64
 
65
+ // Add a function to get safe time values
66
+ const getSafeTimeRange = (segment: LyricsSegment | null) => {
67
+ if (!segment) return { start: 0, end: 1 }; // Default 1-second range
68
+
69
+ const start = segment.start_time ?? 0;
70
+ const end = segment.end_time ?? (start + 1);
71
+ return { start, end };
72
+ }
73
+
61
74
  if (!segment || segmentIndex === null || !editedSegment || !originalSegment) return null
62
75
 
63
- const handleWordChange = (index: number, field: keyof Word, value: string | number) => {
76
+ // Get safe time values for TimelineEditor
77
+ const timeRange = getSafeTimeRange(editedSegment)
78
+
79
+ const handleWordChange = (index: number, updates: Partial<Word>) => {
64
80
  const newWords = [...editedSegment.words]
65
81
  newWords[index] = {
66
82
  ...newWords[index],
67
- [field]: field === 'start_time' || field === 'end_time'
68
- ? parseFloat(Number(value).toFixed(4))
69
- : value
83
+ ...updates
70
84
  }
71
85
  updateSegment(newWords)
72
86
  }
73
87
 
74
88
  const updateSegment = (newWords: Word[]) => {
75
- const segmentStartTime = Math.min(...newWords.map(w => w.start_time))
76
- const segmentEndTime = Math.max(...newWords.map(w => w.end_time))
89
+ // Filter out null values before finding min/max
90
+ const validStartTimes = newWords.map(w => w.start_time).filter((t): t is number => t !== null)
91
+ const validEndTimes = newWords.map(w => w.end_time).filter((t): t is number => t !== null)
92
+
93
+ const segmentStartTime = validStartTimes.length > 0 ? Math.min(...validStartTimes) : null
94
+ const segmentEndTime = validEndTimes.length > 0 ? Math.max(...validEndTimes) : null
77
95
 
78
96
  setEditedSegment({
79
97
  ...editedSegment,
@@ -91,11 +109,12 @@ export default function EditModal({
91
109
  if (index === undefined) {
92
110
  // Add at end
93
111
  const lastWord = newWords[newWords.length - 1]
112
+ const lastEndTime = lastWord.end_time ?? 0
94
113
  newWord = {
95
114
  id: nanoid(),
96
115
  text: '',
97
- start_time: lastWord.end_time,
98
- end_time: lastWord.end_time + 0.5,
116
+ start_time: lastEndTime,
117
+ end_time: lastEndTime + 0.5,
99
118
  confidence: 1.0
100
119
  }
101
120
  newWords.push(newWord)
@@ -104,8 +123,11 @@ export default function EditModal({
104
123
  const prevWord = newWords[index]
105
124
  const nextWord = newWords[index + 1]
106
125
  const midTime = prevWord ?
107
- (nextWord ? (prevWord.end_time + nextWord.start_time) / 2 : prevWord.end_time + 0.5) :
108
- (nextWord ? nextWord.start_time - 0.5 : 0)
126
+ (nextWord ?
127
+ ((prevWord.end_time ?? 0) + (nextWord.start_time ?? 0)) / 2 :
128
+ (prevWord.end_time ?? 0) + 0.5
129
+ ) :
130
+ (nextWord ? (nextWord.start_time ?? 0) - 0.5 : 0)
109
131
 
110
132
  newWord = {
111
133
  id: nanoid(),
@@ -122,7 +144,9 @@ export default function EditModal({
122
144
 
123
145
  const handleSplitWord = (index: number) => {
124
146
  const word = editedSegment.words[index]
125
- const midTime = (word.start_time + word.end_time) / 2
147
+ const startTime = word.start_time ?? 0
148
+ const endTime = word.end_time ?? startTime + 0.5
149
+ const midTime = (startTime + endTime) / 2
126
150
  const words = word.text.split(/\s+/)
127
151
 
128
152
  if (words.length <= 1) {
@@ -138,7 +162,7 @@ export default function EditModal({
138
162
  {
139
163
  id: nanoid(),
140
164
  text: words[0],
141
- start_time: word.start_time,
165
+ start_time: startTime,
142
166
  end_time: midTime,
143
167
  confidence: 1.0
144
168
  },
@@ -146,7 +170,7 @@ export default function EditModal({
146
170
  id: nanoid(),
147
171
  text: words[1],
148
172
  start_time: midTime,
149
- end_time: word.end_time,
173
+ end_time: endTime,
150
174
  confidence: 1.0
151
175
  }
152
176
  )
@@ -164,8 +188,8 @@ export default function EditModal({
164
188
  newWords.splice(index, 2, {
165
189
  id: nanoid(),
166
190
  text: `${word1.text} ${word2.text}`.trim(),
167
- start_time: word1.start_time,
168
- end_time: word2.end_time,
191
+ start_time: word1.start_time ?? null,
192
+ end_time: word2.end_time ?? null,
169
193
  confidence: 1.0
170
194
  })
171
195
 
@@ -198,7 +222,7 @@ export default function EditModal({
198
222
  originalText: segment?.text,
199
223
  editedText: editedSegment.text,
200
224
  wordCount: editedSegment.words.length,
201
- timeRange: `${editedSegment.start_time.toFixed(4)} - ${editedSegment.end_time.toFixed(4)}`
225
+ timeRange: `${editedSegment.start_time?.toFixed(4) ?? 'N/A'} - ${editedSegment.end_time?.toFixed(4) ?? 'N/A'}`
202
226
  })
203
227
  onSave(editedSegment)
204
228
  onClose()
@@ -209,7 +233,9 @@ export default function EditModal({
209
233
  if (!editedSegment) return
210
234
 
211
235
  const newWords = replacementText.trim().split(/\s+/)
212
- const segmentDuration = editedSegment.end_time - editedSegment.start_time
236
+ const startTime = editedSegment.start_time ?? 0
237
+ const endTime = editedSegment.end_time ?? (startTime + newWords.length) // Default to 1 second per word
238
+ const segmentDuration = endTime - startTime
213
239
 
214
240
  let updatedWords: Word[]
215
241
 
@@ -228,8 +254,8 @@ export default function EditModal({
228
254
  updatedWords = newWords.map((text, index) => ({
229
255
  id: nanoid(), // Generate new ID
230
256
  text,
231
- start_time: editedSegment.start_time + (index * avgWordDuration),
232
- end_time: editedSegment.start_time + ((index + 1) * avgWordDuration),
257
+ start_time: startTime + (index * avgWordDuration),
258
+ end_time: startTime + ((index + 1) * avgWordDuration),
233
259
  confidence: 1.0
234
260
  }))
235
261
  }
@@ -252,6 +278,13 @@ export default function EditModal({
252
278
  }
253
279
  }
254
280
 
281
+ const handleSplitSegment = (wordIndex: number) => {
282
+ if (segmentIndex !== null && editedSegment) {
283
+ handleSave() // Save current changes first
284
+ onSplitSegment?.(segmentIndex, wordIndex)
285
+ }
286
+ }
287
+
255
288
  return (
256
289
  <Dialog
257
290
  open={open}
@@ -263,10 +296,10 @@ export default function EditModal({
263
296
  <DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
264
297
  <Box sx={{ flex: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
265
298
  Edit Segment {segmentIndex}
266
- {segment?.start_time !== undefined && onPlaySegment && (
299
+ {segment?.start_time !== null && onPlaySegment && (
267
300
  <IconButton
268
301
  size="small"
269
- onClick={() => onPlaySegment(segment.start_time)}
302
+ onClick={() => onPlaySegment(segment.start_time!)}
270
303
  sx={{ padding: '4px' }}
271
304
  >
272
305
  <PlayCircleOutlineIcon />
@@ -281,22 +314,18 @@ export default function EditModal({
281
314
  <Box sx={{ mb: 2 }}>
282
315
  <TimelineEditor
283
316
  words={editedSegment.words}
284
- startTime={editedSegment.start_time}
285
- endTime={editedSegment.end_time}
286
- onWordUpdate={(index, updates) => {
287
- const newWords = [...editedSegment.words]
288
- newWords[index] = { ...newWords[index], ...updates }
289
- updateSegment(newWords)
290
- }}
317
+ startTime={timeRange.start}
318
+ endTime={timeRange.end}
319
+ onWordUpdate={handleWordChange}
291
320
  currentTime={currentTime}
292
321
  onPlaySegment={onPlaySegment}
293
322
  />
294
323
  </Box>
295
324
 
296
325
  <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
297
- Original Time Range: {originalSegment.start_time.toFixed(2)} - {originalSegment.end_time.toFixed(2)}
326
+ Original Time Range: {originalSegment.start_time?.toFixed(2) ?? 'N/A'} - {originalSegment.end_time?.toFixed(2) ?? 'N/A'}
298
327
  <br />
299
- Current Time Range: {editedSegment.start_time.toFixed(2)} - {editedSegment.end_time.toFixed(2)}
328
+ Current Time Range: {editedSegment.start_time?.toFixed(2) ?? 'N/A'} - {editedSegment.end_time?.toFixed(2) ?? 'N/A'}
300
329
  </Typography>
301
330
 
302
331
  <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mb: 3 }}>
@@ -305,14 +334,14 @@ export default function EditModal({
305
334
  <TextField
306
335
  label={`Word ${index}`}
307
336
  value={word.text}
308
- onChange={(e) => handleWordChange(index, 'text', e.target.value)}
337
+ onChange={(e) => handleWordChange(index, { text: e.target.value })}
309
338
  fullWidth
310
339
  size="small"
311
340
  />
312
341
  <TextField
313
342
  label="Start Time"
314
- value={word.start_time.toFixed(2)}
315
- onChange={(e) => handleWordChange(index, 'start_time', parseFloat(e.target.value))}
343
+ value={word.start_time?.toFixed(2) ?? ''}
344
+ onChange={(e) => handleWordChange(index, { start_time: parseFloat(e.target.value) })}
316
345
  type="number"
317
346
  inputProps={{ step: 0.01 }}
318
347
  sx={{ width: '150px' }}
@@ -320,13 +349,20 @@ export default function EditModal({
320
349
  />
321
350
  <TextField
322
351
  label="End Time"
323
- value={word.end_time.toFixed(2)}
324
- onChange={(e) => handleWordChange(index, 'end_time', parseFloat(e.target.value))}
352
+ value={word.end_time?.toFixed(2) ?? ''}
353
+ onChange={(e) => handleWordChange(index, { end_time: parseFloat(e.target.value) })}
325
354
  type="number"
326
355
  inputProps={{ step: 0.01 }}
327
356
  sx={{ width: '150px' }}
328
357
  size="small"
329
358
  />
359
+ <IconButton
360
+ onClick={() => handleRemoveWord(index)}
361
+ disabled={editedSegment.words.length <= 1}
362
+ sx={{ color: 'error.main' }}
363
+ >
364
+ <DeleteIcon fontSize="small" />
365
+ </IconButton>
330
366
  <IconButton onClick={(e) => handleWordMenu(e, index)}>
331
367
  <MoreVertIcon />
332
368
  </IconButton>
@@ -361,14 +397,22 @@ export default function EditModal({
361
397
  >
362
398
  Reset
363
399
  </Button>
364
- <Button
365
- startIcon={<DeleteIcon />}
366
- onClick={handleDelete}
367
- color="error"
368
- sx={{ mr: 'auto' }}
369
- >
370
- Delete Segment
371
- </Button>
400
+ <Box sx={{ mr: 'auto', display: 'flex', gap: 1 }}>
401
+ <Button
402
+ startIcon={<AddIcon />}
403
+ onClick={() => segmentIndex !== null && onAddSegment?.(segmentIndex)}
404
+ color="primary"
405
+ >
406
+ Add Segment Before
407
+ </Button>
408
+ <Button
409
+ startIcon={<DeleteIcon />}
410
+ onClick={handleDelete}
411
+ color="error"
412
+ >
413
+ Delete Segment
414
+ </Button>
415
+ </Box>
372
416
  <Button onClick={onClose}>Cancel</Button>
373
417
  <Button onClick={handleSave} variant="contained">
374
418
  Save Changes
@@ -392,6 +436,12 @@ export default function EditModal({
392
436
  }}>
393
437
  <SplitIcon sx={{ mr: 1 }} /> Split Word
394
438
  </MenuItem>
439
+ <MenuItem onClick={() => {
440
+ handleSplitSegment(selectedWordIndex!)
441
+ handleMenuClose()
442
+ }}>
443
+ <SplitIcon sx={{ mr: 1 }} /> Split Segment After Word
444
+ </MenuItem>
395
445
  <MenuItem
396
446
  onClick={() => {
397
447
  handleMergeWords(selectedWordIndex!)
@@ -1,10 +1,10 @@
1
1
  import { ChangeEvent, DragEvent, useState } from 'react'
2
2
  import { Paper, Typography } from '@mui/material'
3
3
  import CloudUploadIcon from '@mui/icons-material/CloudUpload'
4
- import { LyricsData } from '../types'
4
+ import { CorrectionData } from '../types'
5
5
 
6
6
  interface FileUploadProps {
7
- onUpload: (data: LyricsData) => void
7
+ onUpload: (data: CorrectionData) => void
8
8
  }
9
9
 
10
10
  export default function FileUpload({ onUpload }: FileUploadProps) {
@@ -0,0 +1,251 @@
1
+ import { Box, Button, Typography, useMediaQuery, useTheme, Switch, FormControlLabel, Tooltip, CircularProgress } from '@mui/material'
2
+ import LockIcon from '@mui/icons-material/Lock'
3
+ import UploadFileIcon from '@mui/icons-material/UploadFile'
4
+ import { CorrectionData } from '../types'
5
+ import CorrectionMetrics from './CorrectionMetrics'
6
+ import ModeSelector from './ModeSelector'
7
+ import AudioPlayer from './AudioPlayer'
8
+ import { InteractionMode } from '../types'
9
+ import { ApiClient } from '../api'
10
+ import { findWordById } from './shared/utils/wordUtils'
11
+
12
+ interface HeaderProps {
13
+ isReadOnly: boolean
14
+ onFileLoad: () => void
15
+ data: CorrectionData
16
+ onMetricClick: {
17
+ anchor: () => void
18
+ corrected: () => void
19
+ uncorrected: () => void
20
+ }
21
+ effectiveMode: InteractionMode
22
+ onModeChange: (mode: InteractionMode) => void
23
+ apiClient: ApiClient | null
24
+ audioHash: string
25
+ onTimeUpdate: (time: number) => void
26
+ onHandlerToggle: (handler: string, enabled: boolean) => void
27
+ isUpdatingHandlers: boolean
28
+ onHandlerClick?: (handler: string) => void
29
+ }
30
+
31
+ export default function Header({
32
+ isReadOnly,
33
+ onFileLoad,
34
+ data,
35
+ onMetricClick,
36
+ effectiveMode,
37
+ onModeChange,
38
+ apiClient,
39
+ audioHash,
40
+ onTimeUpdate,
41
+ onHandlerToggle,
42
+ isUpdatingHandlers,
43
+ onHandlerClick
44
+ }: HeaderProps) {
45
+ const theme = useTheme()
46
+ const isMobile = useMediaQuery(theme.breakpoints.down('md'))
47
+
48
+ // Get handlers with their correction counts
49
+ const handlerCounts = data.corrections?.reduce((counts: Record<string, number>, correction) => {
50
+ counts[correction.handler] = (counts[correction.handler] || 0) + 1
51
+ return counts
52
+ }, {}) || {}
53
+
54
+ // Get available handlers from metadata
55
+ const availableHandlers = data.metadata.available_handlers || []
56
+ const enabledHandlers = new Set(data.metadata.enabled_handlers || [])
57
+
58
+ // Create a map of gap IDs to their corrections
59
+ const gapCorrections = data.corrections.reduce((map: Record<string, number>, correction) => {
60
+ // Find the gap that contains this correction's word_id
61
+ const gap = data.gap_sequences.find(g =>
62
+ g.transcribed_word_ids.includes(correction.word_id)
63
+ )
64
+ if (gap) {
65
+ map[gap.id] = (map[gap.id] || 0) + 1
66
+ }
67
+ return map
68
+ }, {})
69
+
70
+ // Calculate metrics
71
+ const correctedGapCount = Object.keys(gapCorrections).length
72
+ const uncorrectedGapCount = data.gap_sequences.length - correctedGapCount
73
+
74
+ const uncorrectedGaps = data.gap_sequences
75
+ .filter(gap => !gapCorrections[gap.id] && gap.transcribed_word_ids.length > 0)
76
+ .map(gap => {
77
+ const firstWord = findWordById(data.corrected_segments, gap.transcribed_word_ids[0])
78
+ return {
79
+ position: firstWord?.id ?? '',
80
+ length: gap.transcribed_word_ids.length
81
+ }
82
+ })
83
+
84
+ // Calculate correction type counts
85
+ const replacedCount = data.corrections.filter(c => !c.is_deletion && !c.split_total).length
86
+ const addedCount = data.corrections.filter(c => c.split_total).length
87
+ const deletedCount = data.corrections.filter(c => c.is_deletion).length
88
+
89
+ console.log('Header: Render with isUpdatingHandlers =', isUpdatingHandlers)
90
+
91
+ return (
92
+ <>
93
+ {isReadOnly && (
94
+ <Box sx={{ display: 'flex', alignItems: 'center', mb: 2, color: 'text.secondary' }}>
95
+ <LockIcon sx={{ mr: 1 }} />
96
+ <Typography variant="body2">
97
+ View Only Mode
98
+ </Typography>
99
+ </Box>
100
+ )}
101
+
102
+ <Box sx={{
103
+ display: 'flex',
104
+ flexDirection: isMobile ? 'column' : 'row',
105
+ gap: 2,
106
+ justifyContent: 'space-between',
107
+ alignItems: isMobile ? 'stretch' : 'center',
108
+ mb: 3
109
+ }}>
110
+ <Typography variant="h4" sx={{ fontSize: isMobile ? '1.75rem' : '2.125rem' }}>
111
+ Lyrics Correction Review
112
+ </Typography>
113
+ {isReadOnly && (
114
+ <Button
115
+ variant="outlined"
116
+ startIcon={<UploadFileIcon />}
117
+ onClick={onFileLoad}
118
+ fullWidth={isMobile}
119
+ >
120
+ Load File
121
+ </Button>
122
+ )}
123
+ </Box>
124
+
125
+ <Box sx={{
126
+ display: 'flex',
127
+ gap: 2,
128
+ mb: 3,
129
+ flexDirection: isMobile ? 'column' : 'row'
130
+ }}>
131
+ <Box sx={{
132
+ display: 'flex',
133
+ flexDirection: 'column',
134
+ gap: 1,
135
+ minWidth: '250px',
136
+ position: 'relative'
137
+ }}>
138
+ <Typography variant="subtitle2" color="text.secondary">
139
+ Correction Handlers
140
+ </Typography>
141
+
142
+ {availableHandlers.map(handler => (
143
+ <Tooltip
144
+ key={handler.id}
145
+ title={handler.description}
146
+ placement="right"
147
+ >
148
+ <FormControlLabel
149
+ control={
150
+ <Switch
151
+ checked={enabledHandlers.has(handler.id)}
152
+ onChange={(e) => onHandlerToggle(handler.id, e.target.checked)}
153
+ size="small"
154
+ disabled={isUpdatingHandlers}
155
+ />
156
+ }
157
+ label={`${handler.name} (${handlerCounts[handler.id] || 0})`}
158
+ onClick={(e) => {
159
+ if ((e.target as HTMLElement).tagName !== 'INPUT') {
160
+ e.preventDefault();
161
+ e.stopPropagation();
162
+ onHandlerClick?.(handler.id);
163
+ }
164
+ }}
165
+ sx={{
166
+ ml: 0,
167
+ '& .MuiFormControlLabel-label': {
168
+ fontSize: '0.875rem',
169
+ cursor: 'pointer'
170
+ }
171
+ }}
172
+ />
173
+ </Tooltip>
174
+ ))}
175
+
176
+ {isUpdatingHandlers && (
177
+ <Box sx={{
178
+ position: 'absolute',
179
+ top: 0,
180
+ left: 0,
181
+ right: 0,
182
+ bottom: 0,
183
+ display: 'flex',
184
+ alignItems: 'center',
185
+ justifyContent: 'center',
186
+ backgroundColor: 'rgba(255, 255, 255, 0.7)',
187
+ borderRadius: 1,
188
+ zIndex: 1
189
+ }}>
190
+ <Box sx={{
191
+ display: 'flex',
192
+ alignItems: 'center',
193
+ gap: 2,
194
+ padding: 2,
195
+ backgroundColor: 'rgba(255, 255, 255, 0.9)',
196
+ borderRadius: 1,
197
+ boxShadow: 1
198
+ }}>
199
+ <CircularProgress size={24} />
200
+ <Typography variant="body2" color="text.secondary">
201
+ Updating corrections...
202
+ </Typography>
203
+ </Box>
204
+ </Box>
205
+ )}
206
+ </Box>
207
+ <Box sx={{ flexGrow: 1 }}>
208
+ <CorrectionMetrics
209
+ // Anchor metrics
210
+ anchorCount={data.metadata.anchor_sequences_count}
211
+ multiSourceAnchors={data.anchor_sequences?.filter(anchor =>
212
+ anchor?.reference_word_ids &&
213
+ Object.keys(anchor.reference_word_ids).length > 1
214
+ ).length ?? 0}
215
+ anchorWordCount={data.anchor_sequences?.reduce((sum, anchor) =>
216
+ sum + (anchor.transcribed_word_ids?.length || 0), 0) ?? 0}
217
+ // Updated gap metrics
218
+ correctedGapCount={correctedGapCount}
219
+ uncorrectedGapCount={uncorrectedGapCount}
220
+ uncorrectedGaps={uncorrectedGaps}
221
+ // Updated correction type counts
222
+ replacedCount={replacedCount}
223
+ addedCount={addedCount}
224
+ deletedCount={deletedCount}
225
+ onMetricClick={onMetricClick}
226
+ totalWords={data.metadata.total_words}
227
+ />
228
+ </Box>
229
+ </Box>
230
+
231
+ <Box sx={{
232
+ display: 'flex',
233
+ flexDirection: isMobile ? 'column' : 'row',
234
+ gap: 5,
235
+ alignItems: 'flex-start',
236
+ justifyContent: 'flex-start',
237
+ mb: 3
238
+ }}>
239
+ <ModeSelector
240
+ effectiveMode={effectiveMode}
241
+ onChange={onModeChange}
242
+ />
243
+ <AudioPlayer
244
+ apiClient={apiClient}
245
+ onTimeUpdate={onTimeUpdate}
246
+ audioHash={audioHash}
247
+ />
248
+ </Box>
249
+ </>
250
+ )
251
+ }