lyrics-transcriber 0.41.0__py3-none-any.whl → 0.43.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 (78) 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-D0Gr3Ep7.js} +16509 -9038
  20. lyrics_transcriber/frontend/dist/assets/index-D0Gr3Ep7.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 +14 -6
  26. lyrics_transcriber/frontend/src/components/DetailsModal.tsx +86 -59
  27. lyrics_transcriber/frontend/src/components/EditModal.tsx +281 -63
  28. lyrics_transcriber/frontend/src/components/FileUpload.tsx +2 -2
  29. lyrics_transcriber/frontend/src/components/Header.tsx +249 -0
  30. lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +320 -266
  31. lyrics_transcriber/frontend/src/components/PreviewVideoSection.tsx +120 -0
  32. lyrics_transcriber/frontend/src/components/ReferenceView.tsx +174 -52
  33. lyrics_transcriber/frontend/src/components/ReviewChangesModal.tsx +158 -114
  34. lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +59 -78
  35. lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +39 -16
  36. lyrics_transcriber/frontend/src/components/WordEditControls.tsx +4 -10
  37. lyrics_transcriber/frontend/src/components/shared/components/HighlightedText.tsx +134 -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 +67 -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/global.d.ts +9 -0
  48. lyrics_transcriber/frontend/src/types.js +2 -0
  49. lyrics_transcriber/frontend/src/types.ts +70 -49
  50. lyrics_transcriber/frontend/src/validation.ts +132 -0
  51. lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
  52. lyrics_transcriber/frontend/yarn.lock +3752 -0
  53. lyrics_transcriber/lyrics/base_lyrics_provider.py +75 -12
  54. lyrics_transcriber/lyrics/file_provider.py +6 -5
  55. lyrics_transcriber/lyrics/genius.py +5 -2
  56. lyrics_transcriber/lyrics/spotify.py +58 -21
  57. lyrics_transcriber/output/ass/config.py +16 -5
  58. lyrics_transcriber/output/cdg.py +1 -1
  59. lyrics_transcriber/output/generator.py +22 -8
  60. lyrics_transcriber/output/plain_text.py +15 -10
  61. lyrics_transcriber/output/segment_resizer.py +16 -3
  62. lyrics_transcriber/output/subtitles.py +27 -1
  63. lyrics_transcriber/output/video.py +107 -1
  64. lyrics_transcriber/review/__init__.py +0 -1
  65. lyrics_transcriber/review/server.py +337 -164
  66. lyrics_transcriber/transcribers/audioshake.py +3 -0
  67. lyrics_transcriber/transcribers/base_transcriber.py +11 -3
  68. lyrics_transcriber/transcribers/whisper.py +11 -1
  69. lyrics_transcriber/types.py +151 -105
  70. lyrics_transcriber/utils/word_utils.py +27 -0
  71. {lyrics_transcriber-0.41.0.dist-info → lyrics_transcriber-0.43.0.dist-info}/METADATA +3 -1
  72. {lyrics_transcriber-0.41.0.dist-info → lyrics_transcriber-0.43.0.dist-info}/RECORD +75 -61
  73. {lyrics_transcriber-0.41.0.dist-info → lyrics_transcriber-0.43.0.dist-info}/WHEEL +1 -1
  74. lyrics_transcriber/frontend/dist/assets/index-DKnNJHRK.js.map +0 -1
  75. lyrics_transcriber/frontend/package-lock.json +0 -4260
  76. lyrics_transcriber/frontend/src/components/shared/utils/initializeDataWithIds.tsx +0 -202
  77. {lyrics_transcriber-0.41.0.dist-info → lyrics_transcriber-0.43.0.dist-info}/LICENSE +0 -0
  78. {lyrics_transcriber-0.41.0.dist-info → lyrics_transcriber-0.43.0.dist-info}/entry_points.txt +0 -0
@@ -6,22 +6,25 @@ import {
6
6
  InteractionMode,
7
7
  LyricsSegment
8
8
  } from '../types'
9
- import LockIcon from '@mui/icons-material/Lock'
10
- import UploadFileIcon from '@mui/icons-material/UploadFile'
11
- import { Box, Button, Grid, Typography, useMediaQuery, useTheme } from '@mui/material'
9
+ import { Box, Button, Grid, useMediaQuery, useTheme } from '@mui/material'
12
10
  import { useCallback, useState, useEffect } from 'react'
13
11
  import { ApiClient } from '../api'
14
- import CorrectionMetrics from './CorrectionMetrics'
15
12
  import DetailsModal from './DetailsModal'
16
- import ModeSelector from './ModeSelector'
17
13
  import ReferenceView from './ReferenceView'
18
14
  import TranscriptionView from './TranscriptionView'
19
15
  import { WordClickInfo, FlashType } from './shared/types'
20
16
  import EditModal from './EditModal'
21
17
  import ReviewChangesModal from './ReviewChangesModal'
22
- import AudioPlayer from './AudioPlayer'
23
- import { nanoid } from 'nanoid'
24
- import { initializeDataWithIds, normalizeDataForSubmission } from './shared/utils/initializeDataWithIds'
18
+ import {
19
+ addSegmentBefore,
20
+ splitSegment,
21
+ deleteSegment,
22
+ updateSegment
23
+ } from './shared/utils/segmentOperations'
24
+ import { loadSavedData, saveData, clearSavedData } from './shared/utils/localStorage'
25
+ import { setupKeyboardHandlers, setModalHandler } from './shared/utils/keyboardHandlers'
26
+ import Header from './Header'
27
+ import { findWordById, getWordsFromIds } from './shared/utils/wordUtils'
25
28
 
26
29
  // Add type for window augmentation at the top of the file
27
30
  declare global {
@@ -31,12 +34,13 @@ declare global {
31
34
  }
32
35
  }
33
36
 
34
- interface LyricsAnalyzerProps {
37
+ export interface LyricsAnalyzerProps {
35
38
  data: CorrectionData
36
39
  onFileLoad: () => void
37
40
  onShowMetadata: () => void
38
41
  apiClient: ApiClient | null
39
42
  isReadOnly: boolean
43
+ audioHash: string
40
44
  }
41
45
 
42
46
  export type ModalContent = {
@@ -44,26 +48,30 @@ export type ModalContent = {
44
48
  data: AnchorSequence & {
45
49
  wordId: string
46
50
  word?: string
51
+ anchor_sequences: AnchorSequence[]
47
52
  }
48
53
  } | {
49
54
  type: 'gap'
50
55
  data: GapSequence & {
51
56
  wordId: string
52
57
  word: string
58
+ anchor_sequences: AnchorSequence[]
53
59
  }
54
60
  }
55
61
 
56
- export default function LyricsAnalyzer({ data: initialData, onFileLoad, apiClient, isReadOnly }: LyricsAnalyzerProps) {
62
+ export default function LyricsAnalyzer({ data: initialData, onFileLoad, apiClient, isReadOnly, audioHash }: LyricsAnalyzerProps) {
57
63
  const [modalContent, setModalContent] = useState<ModalContent | null>(null)
58
64
  const [flashingType, setFlashingType] = useState<FlashType>(null)
59
65
  const [highlightInfo, setHighlightInfo] = useState<HighlightInfo | null>(null)
60
66
  const [currentSource, setCurrentSource] = useState<string>(() => {
61
- const availableSources = Object.keys(initialData.reference_texts)
67
+ if (!initialData?.reference_lyrics) {
68
+ return ''
69
+ }
70
+ const availableSources = Object.keys(initialData.reference_lyrics)
62
71
  return availableSources.length > 0 ? availableSources[0] : ''
63
72
  })
64
73
  const [isReviewComplete, setIsReviewComplete] = useState(false)
65
- const [data, setData] = useState(() => initializeDataWithIds(initialData))
66
- // Create deep copy of initial data for comparison later
74
+ const [data, setData] = useState(initialData)
67
75
  const [originalData] = useState(() => JSON.parse(JSON.stringify(initialData)))
68
76
  const [interactionMode, setInteractionMode] = useState<InteractionMode>('details')
69
77
  const [isShiftPressed, setIsShiftPressed] = useState(false)
@@ -75,115 +83,62 @@ export default function LyricsAnalyzer({ data: initialData, onFileLoad, apiClien
75
83
  } | null>(null)
76
84
  const [isReviewModalOpen, setIsReviewModalOpen] = useState(false)
77
85
  const [currentAudioTime, setCurrentAudioTime] = useState(0)
86
+ const [isUpdatingHandlers, setIsUpdatingHandlers] = useState(false)
87
+ const [flashingHandler, setFlashingHandler] = useState<string | null>(null)
78
88
  const theme = useTheme()
79
89
  const isMobile = useMediaQuery(theme.breakpoints.down('md'))
80
90
 
81
- // Simple hash function for generating storage keys
82
- const generateStorageKey = (text: string): string => {
83
- let hash = 0;
84
- for (let i = 0; i < text.length; i++) {
85
- const char = text.charCodeAt(i);
86
- hash = ((hash << 5) - hash) + char;
87
- hash = hash & hash; // Convert to 32-bit integer
88
- }
89
- return `song_${hash}`;
90
- }
91
+ // Update debug logging to use new ID-based structure
92
+ useEffect(() => {
93
+ console.log('LyricsAnalyzer Initial Data:', {
94
+ hasData: !!initialData,
95
+ segmentsCount: initialData?.corrected_segments?.length ?? 0,
96
+ anchorsCount: initialData?.anchor_sequences?.length ?? 0,
97
+ gapsCount: initialData?.gap_sequences?.length ?? 0,
98
+ firstAnchor: initialData?.anchor_sequences?.[0] && {
99
+ transcribedWordIds: initialData.anchor_sequences[0].transcribed_word_ids,
100
+ referenceWordIds: initialData.anchor_sequences[0].reference_word_ids
101
+ },
102
+ firstSegment: initialData?.corrected_segments?.[0],
103
+ referenceSources: Object.keys(initialData?.reference_lyrics ?? {})
104
+ });
105
+ }, [initialData]);
91
106
 
92
- // Add local storage handling
107
+ // Load saved data
93
108
  useEffect(() => {
94
- const storageKey = generateStorageKey(initialData.transcribed_text);
95
- const savedDataStr = localStorage.getItem('lyrics_analyzer_data');
96
- const savedDataObj = savedDataStr ? JSON.parse(savedDataStr) : {};
97
-
98
- if (savedDataObj[storageKey]) {
99
- try {
100
- const parsed = savedDataObj[storageKey];
101
- if (parsed.transcribed_text === initialData.transcribed_text) {
102
- const stripIds = (obj: CorrectionData): LyricsSegment[] => {
103
- const clone = JSON.parse(JSON.stringify(obj));
104
- return clone.corrected_segments.map((segment: LyricsSegment) => {
105
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
106
- const { id: _id, ...strippedSegment } = segment;
107
- return {
108
- ...strippedSegment,
109
- words: segment.words.map(word => {
110
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
111
- const { id: _wordId, ...strippedWord } = word;
112
- return strippedWord;
113
- })
114
- };
115
- });
116
- };
117
-
118
- const strippedSaved = stripIds(parsed);
119
- const strippedInitial = stripIds(initialData);
120
-
121
- const hasChanges = JSON.stringify(strippedSaved) !== JSON.stringify(strippedInitial);
122
-
123
- if (hasChanges && window.confirm('Found saved progress for this song. Would you like to restore it?')) {
124
- setData(parsed);
125
- } else if (!hasChanges) {
126
- delete savedDataObj[storageKey];
127
- localStorage.setItem('lyrics_analyzer_data', JSON.stringify(savedDataObj));
128
- }
129
- }
130
- } catch (error) {
131
- console.error('Failed to parse saved data:', error);
132
- delete savedDataObj[storageKey];
133
- localStorage.setItem('lyrics_analyzer_data', JSON.stringify(savedDataObj));
134
- }
109
+ const savedData = loadSavedData(initialData)
110
+ if (savedData && window.confirm('Found saved progress for this song. Would you like to restore it?')) {
111
+ setData(savedData)
135
112
  }
136
- }, [initialData]);
113
+ }, [initialData])
137
114
 
138
- // Save to local storage whenever data changes
115
+ // Save data
139
116
  useEffect(() => {
140
117
  if (!isReadOnly) {
141
- const storageKey = generateStorageKey(initialData.transcribed_text);
142
- const savedDataStr = localStorage.getItem('lyrics_analyzer_data');
143
- const savedDataObj = savedDataStr ? JSON.parse(savedDataStr) : {};
144
-
145
- savedDataObj[storageKey] = data;
146
- localStorage.setItem('lyrics_analyzer_data', JSON.stringify(savedDataObj));
118
+ saveData(data, initialData)
147
119
  }
148
- }, [data, isReadOnly, initialData.transcribed_text]);
120
+ }, [data, isReadOnly, initialData])
149
121
 
150
- // Update keyboard event handler
122
+ // Keyboard handlers
151
123
  useEffect(() => {
152
- const handleKeyDown = (e: KeyboardEvent) => {
153
- if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
154
- return
155
- }
124
+ console.log('Setting up keyboard handlers in LyricsAnalyzer')
156
125
 
157
- if (e.key === 'Shift') {
158
- setIsShiftPressed(true)
159
- document.body.style.userSelect = 'none'
160
- } else if (e.key === 'Meta') {
161
- setIsCtrlPressed(true)
162
- } else if (e.key === ' ' || e.code === 'Space') {
163
- e.preventDefault()
164
- if (window.toggleAudioPlayback) {
165
- window.toggleAudioPlayback()
166
- }
167
- }
168
- }
169
-
170
- const handleKeyUp = (e: KeyboardEvent) => {
171
- if (e.key === 'Shift') {
172
- setIsShiftPressed(false)
173
- document.body.style.userSelect = ''
174
- } else if (e.key === 'Meta') {
175
- setIsCtrlPressed(false)
176
- }
177
- }
126
+ const { handleKeyDown, handleKeyUp } = setupKeyboardHandlers({
127
+ setIsShiftPressed,
128
+ setIsCtrlPressed
129
+ })
178
130
 
131
+ console.log('Adding keyboard event listeners')
179
132
  window.addEventListener('keydown', handleKeyDown)
180
133
  window.addEventListener('keyup', handleKeyUp)
134
+
181
135
  return () => {
136
+ console.log('Removing keyboard event listeners')
182
137
  window.removeEventListener('keydown', handleKeyDown)
183
138
  window.removeEventListener('keyup', handleKeyUp)
184
139
  document.body.style.userSelect = ''
185
140
  }
186
- }, [])
141
+ }, [setIsShiftPressed, setIsCtrlPressed])
187
142
 
188
143
  // Calculate effective mode based on modifier key states
189
144
  const effectiveMode = isShiftPressed ? 'highlight' :
@@ -209,85 +164,175 @@ export default function LyricsAnalyzer({ data: initialData, onFileLoad, apiClien
209
164
  }, [])
210
165
 
211
166
  const handleWordClick = useCallback((info: WordClickInfo) => {
212
- if (effectiveMode === 'edit') {
213
- const segment = data.corrected_segments.find(segment =>
167
+ console.log('LyricsAnalyzer handleWordClick:', { info });
168
+
169
+ if (effectiveMode === 'highlight') {
170
+ // Find if this word is part of a correction
171
+ const correction = data.corrections?.find(c =>
172
+ c.corrected_word_id === info.word_id ||
173
+ c.word_id === info.word_id
174
+ );
175
+
176
+ if (correction) {
177
+ setHighlightInfo({
178
+ type: 'correction',
179
+ transcribed_words: [], // Required by type but not used for corrections
180
+ correction: correction
181
+ });
182
+ setFlashingType('word');
183
+ return;
184
+ }
185
+
186
+ // Find if this word is part of an anchor sequence
187
+ const anchor = data.anchor_sequences?.find(a =>
188
+ a.transcribed_word_ids.includes(info.word_id) ||
189
+ Object.values(a.reference_word_ids).some(ids =>
190
+ ids.includes(info.word_id)
191
+ )
192
+ );
193
+
194
+ if (anchor) {
195
+ // Create a temporary segment containing all words
196
+ const allWords = data.corrected_segments.flatMap(s => s.words)
197
+ const tempSegment: LyricsSegment = {
198
+ id: 'temp',
199
+ words: allWords,
200
+ text: allWords.map(w => w.text).join(' '),
201
+ start_time: allWords[0]?.start_time ?? null,
202
+ end_time: allWords[allWords.length - 1]?.end_time ?? null
203
+ }
204
+
205
+ const transcribedWords = getWordsFromIds(
206
+ [tempSegment],
207
+ anchor.transcribed_word_ids
208
+ );
209
+
210
+ const referenceWords = Object.fromEntries(
211
+ Object.entries(anchor.reference_word_ids).map(([source, ids]) => {
212
+ const sourceWords = data.reference_lyrics[source].segments.flatMap(s => s.words)
213
+ const tempSourceSegment: LyricsSegment = {
214
+ id: `temp-${source}`,
215
+ words: sourceWords,
216
+ text: sourceWords.map(w => w.text).join(' '),
217
+ start_time: sourceWords[0]?.start_time ?? null,
218
+ end_time: sourceWords[sourceWords.length - 1]?.end_time ?? null
219
+ }
220
+ return [
221
+ source,
222
+ getWordsFromIds([tempSourceSegment], ids)
223
+ ]
224
+ })
225
+ );
226
+
227
+ setHighlightInfo({
228
+ type: 'anchor',
229
+ sequence: anchor,
230
+ transcribed_words: transcribedWords,
231
+ reference_words: referenceWords
232
+ });
233
+ setFlashingType('word');
234
+ return;
235
+ }
236
+
237
+ // Find if this word is part of a gap sequence
238
+ const gap = data.gap_sequences?.find(g =>
239
+ g.transcribed_word_ids.includes(info.word_id) ||
240
+ Object.values(g.reference_word_ids).some(ids =>
241
+ ids.includes(info.word_id)
242
+ )
243
+ );
244
+
245
+ if (gap) {
246
+ const allWords = data.corrected_segments.flatMap(s => s.words)
247
+ const tempSegment: LyricsSegment = {
248
+ id: 'temp',
249
+ words: allWords,
250
+ text: allWords.map(w => w.text).join(' '),
251
+ start_time: allWords[0]?.start_time ?? null,
252
+ end_time: allWords[allWords.length - 1]?.end_time ?? null
253
+ }
254
+
255
+ const transcribedWords = getWordsFromIds(
256
+ [tempSegment],
257
+ gap.transcribed_word_ids
258
+ );
259
+
260
+ const referenceWords = Object.fromEntries(
261
+ Object.entries(gap.reference_word_ids).map(([source, ids]) => {
262
+ const sourceWords = data.reference_lyrics[source].segments.flatMap(s => s.words)
263
+ const tempSourceSegment: LyricsSegment = {
264
+ id: `temp-${source}`,
265
+ words: sourceWords,
266
+ text: sourceWords.map(w => w.text).join(' '),
267
+ start_time: sourceWords[0]?.start_time ?? null,
268
+ end_time: sourceWords[sourceWords.length - 1]?.end_time ?? null
269
+ }
270
+ return [
271
+ source,
272
+ getWordsFromIds([tempSourceSegment], ids)
273
+ ]
274
+ })
275
+ );
276
+
277
+ setHighlightInfo({
278
+ type: 'gap',
279
+ sequence: gap,
280
+ transcribed_words: transcribedWords,
281
+ reference_words: referenceWords
282
+ });
283
+ setFlashingType('word');
284
+ return;
285
+ }
286
+ } else if (effectiveMode === 'edit') {
287
+ // Find the segment containing this word
288
+ const segmentIndex = data.corrected_segments.findIndex(segment =>
214
289
  segment.words.some(word => word.id === info.word_id)
215
- )
290
+ );
216
291
 
217
- if (segment) {
218
- const segmentIndex = data.corrected_segments.indexOf(segment)
292
+ if (segmentIndex !== -1) {
293
+ const segment = data.corrected_segments[segmentIndex];
219
294
  setEditModalSegment({
220
295
  segment,
221
296
  index: segmentIndex,
222
- originalSegment: originalData.corrected_segments[segmentIndex]
223
- })
297
+ originalSegment: JSON.parse(JSON.stringify(segment))
298
+ });
224
299
  }
225
- } else {
226
- // Update flash handling for anchors/gaps
300
+ } else if (effectiveMode === 'details') {
227
301
  if (info.type === 'anchor' && info.anchor) {
228
- handleFlash('word', {
302
+ const word = findWordById(data.corrected_segments, info.word_id);
303
+ setModalContent({
229
304
  type: 'anchor',
230
- word_ids: info.anchor.word_ids,
231
- reference_word_ids: info.anchor.reference_word_ids
232
- })
305
+ data: {
306
+ ...info.anchor,
307
+ wordId: info.word_id,
308
+ word: word?.text,
309
+ anchor_sequences: data.anchor_sequences
310
+ }
311
+ });
233
312
  } else if (info.type === 'gap' && info.gap) {
234
- handleFlash('word', {
313
+ const word = findWordById(data.corrected_segments, info.word_id);
314
+ setModalContent({
235
315
  type: 'gap',
236
- word_ids: info.gap.word_ids
237
- })
316
+ data: {
317
+ ...info.gap,
318
+ wordId: info.word_id,
319
+ word: word?.text || '',
320
+ anchor_sequences: data.anchor_sequences
321
+ }
322
+ });
238
323
  }
239
324
  }
240
- }, [effectiveMode, data.corrected_segments, handleFlash, originalData.corrected_segments])
325
+ }, [data, effectiveMode, setModalContent]);
241
326
 
242
327
  const handleUpdateSegment = useCallback((updatedSegment: LyricsSegment) => {
243
328
  if (!editModalSegment) return
244
-
245
- const newData = { ...data }
246
-
247
- // Ensure new words have IDs
248
- updatedSegment.words = updatedSegment.words.map(word => ({
249
- ...word,
250
- id: word.id || nanoid()
251
- }))
252
-
253
- newData.corrected_segments[editModalSegment.index] = updatedSegment
254
-
255
- // Update corrected_text
256
- newData.corrected_text = newData.corrected_segments
257
- .map(segment => segment.text)
258
- .join('\n')
259
-
329
+ const newData = updateSegment(data, editModalSegment.index, updatedSegment)
260
330
  setData(newData)
261
331
  setEditModalSegment(null)
262
332
  }, [data, editModalSegment])
263
333
 
264
334
  const handleDeleteSegment = useCallback((segmentIndex: number) => {
265
- const newData = { ...data }
266
- const deletedSegment = newData.corrected_segments[segmentIndex]
267
-
268
- // Remove segment
269
- newData.corrected_segments = newData.corrected_segments.filter((_, index) => index !== segmentIndex)
270
-
271
- // Update anchor and gap sequences to remove references to deleted words
272
- newData.anchor_sequences = newData.anchor_sequences.map(anchor => ({
273
- ...anchor,
274
- word_ids: anchor.word_ids.filter(id =>
275
- !deletedSegment.words.some(word => word.id === id)
276
- )
277
- }))
278
-
279
- newData.gap_sequences = newData.gap_sequences.map(gap => ({
280
- ...gap,
281
- word_ids: gap.word_ids.filter(id =>
282
- !deletedSegment.words.some(word => word.id === id)
283
- )
284
- }))
285
-
286
- // Update corrected_text
287
- newData.corrected_text = newData.corrected_segments
288
- .map(segment => segment.text)
289
- .join('\n')
290
-
335
+ const newData = deleteSegment(data, segmentIndex)
291
336
  setData(newData)
292
337
  }, [data])
293
338
 
@@ -300,8 +345,7 @@ export default function LyricsAnalyzer({ data: initialData, onFileLoad, apiClien
300
345
 
301
346
  try {
302
347
  console.log('Submitting changes to server')
303
- const dataToSubmit = normalizeDataForSubmission(data)
304
- await apiClient.submitCorrections(dataToSubmit)
348
+ await apiClient.submitCorrections(data)
305
349
 
306
350
  setIsReviewComplete(true)
307
351
  setIsReviewModalOpen(false)
@@ -323,114 +367,111 @@ export default function LyricsAnalyzer({ data: initialData, onFileLoad, apiClien
323
367
 
324
368
  const handleResetCorrections = useCallback(() => {
325
369
  if (window.confirm('Are you sure you want to reset all corrections? This cannot be undone.')) {
326
- const storageKey = generateStorageKey(initialData.transcribed_text);
327
- const savedDataStr = localStorage.getItem('lyrics_analyzer_data');
328
- const savedDataObj = savedDataStr ? JSON.parse(savedDataStr) : {};
370
+ clearSavedData(initialData)
371
+ setData(JSON.parse(JSON.stringify(initialData)))
372
+ setModalContent(null)
373
+ setFlashingType(null)
374
+ setHighlightInfo(null)
375
+ setInteractionMode('details')
376
+ }
377
+ }, [initialData])
329
378
 
330
- // Remove only this song's data
331
- delete savedDataObj[storageKey];
332
- localStorage.setItem('lyrics_analyzer_data', JSON.stringify(savedDataObj));
379
+ const handleAddSegment = useCallback((beforeIndex: number) => {
380
+ const newData = addSegmentBefore(data, beforeIndex)
381
+ setData(newData)
382
+ }, [data])
333
383
 
334
- // Reset data to initial state with proper initialization
335
- const freshData = initializeDataWithIds(JSON.parse(JSON.stringify(initialData)));
336
- setData(freshData);
384
+ const handleSplitSegment = useCallback((segmentIndex: number, afterWordIndex: number) => {
385
+ const newData = splitSegment(data, segmentIndex, afterWordIndex)
386
+ if (newData) {
387
+ setData(newData)
388
+ setEditModalSegment(null)
389
+ }
390
+ }, [data])
337
391
 
338
- // Reset any UI state that might affect highlights
339
- setModalContent(null);
340
- setFlashingType(null);
341
- setHighlightInfo(null);
342
- setInteractionMode('details');
392
+ const handleHandlerToggle = useCallback(async (handler: string, enabled: boolean) => {
393
+ if (!apiClient) return
394
+
395
+ try {
396
+ setIsUpdatingHandlers(true);
397
+
398
+ // Get current enabled handlers
399
+ const currentEnabled = new Set(data.metadata.enabled_handlers || [])
400
+
401
+ // Update the set based on the toggle
402
+ if (enabled) {
403
+ currentEnabled.add(handler)
404
+ } else {
405
+ currentEnabled.delete(handler)
406
+ }
407
+
408
+ // Call API to update handlers
409
+ const newData = await apiClient.updateHandlers(Array.from(currentEnabled))
410
+
411
+ // Update local state with new correction data
412
+ setData(newData)
413
+
414
+ // Clear any existing modals or highlights
415
+ setModalContent(null)
416
+ setFlashingType(null)
417
+ setHighlightInfo(null)
418
+
419
+ // Flash the updated corrections
420
+ handleFlash('corrected')
421
+ } catch (error) {
422
+ console.error('Failed to update handlers:', error)
423
+ alert('Failed to update correction handlers. Please try again.')
424
+ } finally {
425
+ setIsUpdatingHandlers(false);
343
426
  }
344
- }, [initialData]);
427
+ }, [apiClient, data.metadata.enabled_handlers, handleFlash])
428
+
429
+ const handleHandlerClick = useCallback((handler: string) => {
430
+ console.log('Handler clicked:', handler);
431
+ setFlashingHandler(handler);
432
+ setFlashingType('handler');
433
+ console.log('Set flashingHandler to:', handler);
434
+ console.log('Set flashingType to: handler');
435
+
436
+ // Clear the flash after a short delay
437
+ setTimeout(() => {
438
+ console.log('Clearing flash state');
439
+ setFlashingHandler(null);
440
+ setFlashingType(null);
441
+ }, 1500);
442
+ }, []);
443
+
444
+ // Wrap setModalSpacebarHandler in useCallback
445
+ const handleSetModalSpacebarHandler = useCallback((handler: (() => (e: KeyboardEvent) => void) | undefined) => {
446
+ // Update the global modal handler
447
+ setModalHandler(handler ? handler() : undefined, !!handler)
448
+ }, [])
345
449
 
346
450
  return (
347
- <Box>
348
- {isReadOnly && (
349
- <Box sx={{ display: 'flex', alignItems: 'center', mb: 2, color: 'text.secondary' }}>
350
- <LockIcon sx={{ mr: 1 }} />
351
- <Typography variant="body2">
352
- View Only Mode
353
- </Typography>
354
- </Box>
355
- )}
356
- <Box sx={{
357
- display: 'flex',
358
- flexDirection: isMobile ? 'column' : 'row',
359
- gap: 2,
360
- justifyContent: 'space-between',
361
- alignItems: isMobile ? 'stretch' : 'center',
362
- mb: 3
363
- }}>
364
- <Typography variant="h4" sx={{ fontSize: isMobile ? '1.75rem' : '2.125rem' }}>
365
- Lyrics Correction Review
366
- </Typography>
367
- {isReadOnly && (
368
- <Button
369
- variant="outlined"
370
- startIcon={<UploadFileIcon />}
371
- onClick={onFileLoad}
372
- fullWidth={isMobile}
373
- >
374
- Load File
375
- </Button>
376
- )}
377
- </Box>
378
-
379
- <Box sx={{ mb: 3 }}>
380
- <CorrectionMetrics
381
- // Anchor metrics
382
- anchorCount={data.metadata.anchor_sequences_count}
383
- multiSourceAnchors={data.anchor_sequences?.filter(anchor =>
384
- // Add null checks
385
- anchor?.reference_word_ids &&
386
- Object.keys(anchor.reference_word_ids || {}).length > 1
387
- ).length ?? 0}
388
- anchorWordCount={data.anchor_sequences?.reduce((sum, anchor) =>
389
- sum + (anchor.length || 0), 0) ?? 0}
390
- // Gap metrics
391
- correctedGapCount={data.gap_sequences?.filter(gap =>
392
- gap.corrections?.length > 0).length ?? 0}
393
- uncorrectedGapCount={data.gap_sequences?.filter(gap =>
394
- !gap.corrections?.length).length ?? 0}
395
- uncorrectedGaps={data.gap_sequences
396
- ?.filter(gap => !gap.corrections?.length && gap.word_ids)
397
- .map(gap => ({
398
- position: gap.word_ids?.[0] ?? '',
399
- length: gap.length ?? 0
400
- })) ?? []}
401
- // Correction details
402
- replacedCount={data.gap_sequences?.reduce((count, gap) =>
403
- count + (gap.corrections?.filter(c => !c.is_deletion && !c.split_total).length ?? 0), 0) ?? 0}
404
- addedCount={data.gap_sequences?.reduce((count, gap) =>
405
- count + (gap.corrections?.filter(c => c.split_total).length ?? 0), 0) ?? 0}
406
- deletedCount={data.gap_sequences?.reduce((count, gap) =>
407
- count + (gap.corrections?.filter(c => c.is_deletion).length ?? 0), 0) ?? 0}
408
- onMetricClick={{
409
- anchor: () => handleFlash('anchor'),
410
- corrected: () => handleFlash('corrected'),
411
- uncorrected: () => handleFlash('uncorrected')
412
- }}
413
- totalWords={data.metadata.total_words}
414
- />
415
- </Box>
416
-
417
- <Box sx={{
418
- display: 'flex',
419
- flexDirection: isMobile ? 'column' : 'row',
420
- gap: 5,
421
- alignItems: 'flex-start',
422
- justifyContent: 'flex-start',
423
- mb: 3
424
- }}>
425
- <ModeSelector
426
- effectiveMode={effectiveMode}
427
- onChange={setInteractionMode}
428
- />
429
- <AudioPlayer
430
- apiClient={apiClient}
431
- onTimeUpdate={setCurrentAudioTime}
432
- />
433
- </Box>
451
+ <Box sx={{
452
+ p: 3,
453
+ pb: 6,
454
+ maxWidth: '100%',
455
+ overflowX: 'hidden'
456
+ }}>
457
+ <Header
458
+ isReadOnly={isReadOnly}
459
+ onFileLoad={onFileLoad}
460
+ data={data}
461
+ onMetricClick={{
462
+ anchor: () => handleFlash('anchor'),
463
+ corrected: () => handleFlash('corrected'),
464
+ uncorrected: () => handleFlash('uncorrected')
465
+ }}
466
+ effectiveMode={effectiveMode}
467
+ onModeChange={setInteractionMode}
468
+ apiClient={apiClient}
469
+ audioHash={audioHash}
470
+ onTimeUpdate={setCurrentAudioTime}
471
+ onHandlerToggle={handleHandlerToggle}
472
+ isUpdatingHandlers={isUpdatingHandlers}
473
+ onHandlerClick={handleHandlerClick}
474
+ />
434
475
 
435
476
  <Grid container spacing={2} direction={isMobile ? 'column' : 'row'}>
436
477
  <Grid item xs={12} md={6}>
@@ -440,14 +481,16 @@ export default function LyricsAnalyzer({ data: initialData, onFileLoad, apiClien
440
481
  onElementClick={setModalContent}
441
482
  onWordClick={handleWordClick}
442
483
  flashingType={flashingType}
484
+ flashingHandler={flashingHandler}
443
485
  highlightInfo={highlightInfo}
444
486
  onPlaySegment={handlePlaySegment}
445
487
  currentTime={currentAudioTime}
488
+ anchors={data.anchor_sequences}
446
489
  />
447
490
  </Grid>
448
491
  <Grid item xs={12} md={6}>
449
492
  <ReferenceView
450
- referenceTexts={data.reference_texts}
493
+ referenceSources={data.reference_lyrics}
451
494
  anchors={data.anchor_sequences}
452
495
  gaps={data.gap_sequences}
453
496
  mode={effectiveMode}
@@ -458,6 +501,7 @@ export default function LyricsAnalyzer({ data: initialData, onFileLoad, apiClien
458
501
  currentSource={currentSource}
459
502
  onSourceChange={setCurrentSource}
460
503
  corrected_segments={data.corrected_segments}
504
+ corrections={data.corrections}
461
505
  />
462
506
  </Grid>
463
507
  </Grid>
@@ -466,18 +510,26 @@ export default function LyricsAnalyzer({ data: initialData, onFileLoad, apiClien
466
510
  open={modalContent !== null}
467
511
  content={modalContent}
468
512
  onClose={() => setModalContent(null)}
513
+ allCorrections={data.corrections}
514
+ referenceLyrics={data.reference_lyrics}
469
515
  />
470
516
 
471
517
  <EditModal
472
518
  open={Boolean(editModalSegment)}
473
- onClose={() => setEditModalSegment(null)}
519
+ onClose={() => {
520
+ setEditModalSegment(null)
521
+ handleSetModalSpacebarHandler(undefined)
522
+ }}
474
523
  segment={editModalSegment?.segment ?? null}
475
524
  segmentIndex={editModalSegment?.index ?? null}
476
525
  originalSegment={editModalSegment?.originalSegment ?? null}
477
526
  onSave={handleUpdateSegment}
478
527
  onDelete={handleDeleteSegment}
528
+ onAddSegment={handleAddSegment}
529
+ onSplitSegment={handleSplitSegment}
479
530
  onPlaySegment={handlePlaySegment}
480
531
  currentTime={currentAudioTime}
532
+ setModalSpacebarHandler={handleSetModalSpacebarHandler}
481
533
  />
482
534
 
483
535
  <ReviewChangesModal
@@ -486,6 +538,8 @@ export default function LyricsAnalyzer({ data: initialData, onFileLoad, apiClien
486
538
  originalData={originalData}
487
539
  updatedData={data}
488
540
  onSubmit={handleSubmitToServer}
541
+ apiClient={apiClient}
542
+ setModalSpacebarHandler={handleSetModalSpacebarHandler}
489
543
  />
490
544
 
491
545
  {!isReadOnly && apiClient && (