lyrics-transcriber 0.35.1__py3-none-any.whl → 0.37.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 (41) hide show
  1. lyrics_transcriber/cli/cli_main.py +2 -0
  2. lyrics_transcriber/core/config.py +1 -1
  3. lyrics_transcriber/core/controller.py +35 -2
  4. lyrics_transcriber/correction/corrector.py +8 -8
  5. lyrics_transcriber/correction/handlers/base.py +4 -0
  6. lyrics_transcriber/correction/handlers/extend_anchor.py +9 -0
  7. lyrics_transcriber/correction/handlers/no_space_punct_match.py +21 -10
  8. lyrics_transcriber/correction/handlers/relaxed_word_count_match.py +21 -11
  9. lyrics_transcriber/correction/handlers/syllables_match.py +4 -4
  10. lyrics_transcriber/correction/handlers/word_count_match.py +19 -10
  11. lyrics_transcriber/frontend/dist/assets/index-BNNbsbVN.js +182 -0
  12. lyrics_transcriber/frontend/dist/index.html +1 -1
  13. lyrics_transcriber/frontend/src/components/AudioPlayer.tsx +18 -7
  14. lyrics_transcriber/frontend/src/components/CorrectionMetrics.tsx +28 -27
  15. lyrics_transcriber/frontend/src/components/DetailsModal.tsx +108 -12
  16. lyrics_transcriber/frontend/src/components/EditModal.tsx +10 -2
  17. lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +145 -141
  18. lyrics_transcriber/frontend/src/components/ReferenceView.tsx +7 -2
  19. lyrics_transcriber/frontend/src/components/ReviewChangesModal.tsx +24 -12
  20. lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +8 -15
  21. lyrics_transcriber/frontend/src/components/WordEditControls.tsx +3 -3
  22. lyrics_transcriber/frontend/src/components/shared/components/HighlightedText.tsx +36 -51
  23. lyrics_transcriber/frontend/src/components/shared/components/SourceSelector.tsx +17 -19
  24. lyrics_transcriber/frontend/src/components/shared/hooks/useWordClick.ts +41 -33
  25. lyrics_transcriber/frontend/src/components/shared/types.ts +6 -6
  26. lyrics_transcriber/frontend/src/components/shared/utils/initializeDataWithIds.tsx +146 -0
  27. lyrics_transcriber/frontend/src/components/shared/utils/referenceLineCalculator.ts +24 -25
  28. lyrics_transcriber/frontend/src/types.ts +24 -23
  29. lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
  30. lyrics_transcriber/lyrics/base_lyrics_provider.py +1 -0
  31. lyrics_transcriber/lyrics/file_provider.py +89 -0
  32. lyrics_transcriber/output/cdg.py +32 -6
  33. lyrics_transcriber/output/video.py +17 -7
  34. lyrics_transcriber/review/server.py +24 -8
  35. {lyrics_transcriber-0.35.1.dist-info → lyrics_transcriber-0.37.0.dist-info}/METADATA +1 -1
  36. {lyrics_transcriber-0.35.1.dist-info → lyrics_transcriber-0.37.0.dist-info}/RECORD +39 -38
  37. {lyrics_transcriber-0.35.1.dist-info → lyrics_transcriber-0.37.0.dist-info}/entry_points.txt +1 -0
  38. lyrics_transcriber/frontend/dist/assets/index-CQCER5Fo.js +0 -181
  39. lyrics_transcriber/frontend/src/components/shared/utils/newlineCalculator.ts +0 -37
  40. {lyrics_transcriber-0.35.1.dist-info → lyrics_transcriber-0.37.0.dist-info}/LICENSE +0 -0
  41. {lyrics_transcriber-0.35.1.dist-info → lyrics_transcriber-0.37.0.dist-info}/WHEEL +0 -0
@@ -1,8 +1,9 @@
1
1
  import {
2
+ AnchorSequence,
2
3
  CorrectionData,
4
+ GapSequence,
3
5
  HighlightInfo,
4
6
  InteractionMode,
5
- LyricsData,
6
7
  LyricsSegment
7
8
  } from '../types'
8
9
  import LockIcon from '@mui/icons-material/Lock'
@@ -19,6 +20,16 @@ import { WordClickInfo, FlashType } from './shared/types'
19
20
  import EditModal from './EditModal'
20
21
  import ReviewChangesModal from './ReviewChangesModal'
21
22
  import AudioPlayer from './AudioPlayer'
23
+ import { nanoid } from 'nanoid'
24
+ import { initializeDataWithIds, normalizeDataForSubmission } from './shared/utils/initializeDataWithIds'
25
+
26
+ // Add type for window augmentation at the top of the file
27
+ declare global {
28
+ interface Window {
29
+ toggleAudioPlayback?: () => void;
30
+ seekAndPlayAudio?: (startTime: number) => void;
31
+ }
32
+ }
22
33
 
23
34
  interface LyricsAnalyzerProps {
24
35
  data: CorrectionData
@@ -30,51 +41,28 @@ interface LyricsAnalyzerProps {
30
41
 
31
42
  export type ModalContent = {
32
43
  type: 'anchor'
33
- data: LyricsData['anchor_sequences'][0] & {
34
- position: number
44
+ data: AnchorSequence & {
45
+ wordId: string
35
46
  word?: string
36
47
  }
37
48
  } | {
38
49
  type: 'gap'
39
- data: LyricsData['gap_sequences'][0] & {
40
- position: number
50
+ data: GapSequence & {
51
+ wordId: string
41
52
  word: string
42
53
  }
43
54
  }
44
55
 
45
- function normalizeDataForSubmission(data: CorrectionData): CorrectionData {
46
- // Create a deep clone to avoid modifying the original
47
- const normalized = JSON.parse(JSON.stringify(data))
48
-
49
- // Preserve floating point numbers with original precision
50
- const preserveFloats = (obj: Record<string, unknown>): void => {
51
- for (const key in obj) {
52
- const value = obj[key]
53
- if (typeof value === 'number') {
54
- // Handle integers and floats differently
55
- let formatted: string
56
- if (Number.isInteger(value)) {
57
- formatted = value.toFixed(1) // Force decimal point for integers
58
- } else {
59
- formatted = value.toString() // Keep original precision for floats
60
- }
61
- obj[key] = parseFloat(formatted)
62
- } else if (typeof value === 'object' && value !== null) {
63
- preserveFloats(value as Record<string, unknown>)
64
- }
65
- }
66
- }
67
- preserveFloats(normalized)
68
- return normalized
69
- }
70
-
71
56
  export default function LyricsAnalyzer({ data: initialData, onFileLoad, apiClient, isReadOnly }: LyricsAnalyzerProps) {
72
57
  const [modalContent, setModalContent] = useState<ModalContent | null>(null)
73
58
  const [flashingType, setFlashingType] = useState<FlashType>(null)
74
59
  const [highlightInfo, setHighlightInfo] = useState<HighlightInfo | null>(null)
75
- const [currentSource, setCurrentSource] = useState<'genius' | 'spotify'>('genius')
60
+ const [currentSource, setCurrentSource] = useState<string>(() => {
61
+ const availableSources = Object.keys(initialData.reference_texts)
62
+ return availableSources.length > 0 ? availableSources[0] : ''
63
+ })
76
64
  const [isReviewComplete, setIsReviewComplete] = useState(false)
77
- const [data, setData] = useState(initialData)
65
+ const [data, setData] = useState(() => initializeDataWithIds(initialData))
78
66
  // Create deep copy of initial data for comparison later
79
67
  const [originalData] = useState(() => JSON.parse(JSON.stringify(initialData)))
80
68
  const [interactionMode, setInteractionMode] = useState<InteractionMode>('details')
@@ -90,39 +78,62 @@ export default function LyricsAnalyzer({ data: initialData, onFileLoad, apiClien
90
78
  const theme = useTheme()
91
79
  const isMobile = useMediaQuery(theme.breakpoints.down('md'))
92
80
 
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
+
93
92
  // Add local storage handling
94
93
  useEffect(() => {
95
94
  // On mount, try to load saved data
96
- const savedData = localStorage.getItem('lyrics_analyzer_data')
97
- if (savedData) {
95
+ const storageKey = generateStorageKey(initialData.transcribed_text);
96
+ const savedDataStr = localStorage.getItem('lyrics_analyzer_data');
97
+ const savedDataObj = savedDataStr ? JSON.parse(savedDataStr) : {};
98
+
99
+ if (savedDataObj[storageKey]) {
98
100
  try {
99
- const parsed = JSON.parse(savedData)
100
- // Only restore if it's the same song (matching transcribed text)
101
+ const parsed = savedDataObj[storageKey];
102
+ // Verify it's the same song (extra safety check)
101
103
  if (parsed.transcribed_text === initialData.transcribed_text) {
102
- console.log('Restored saved progress from local storage')
103
- setData(parsed)
104
- } else {
105
- // Clear old data if it's a different song
106
- localStorage.removeItem('lyrics_analyzer_data')
104
+ if (window.confirm('Found saved progress for this song. Would you like to restore it?')) {
105
+ console.log('Restored saved progress from local storage');
106
+ setData(parsed);
107
+ } else {
108
+ // User declined to restore - remove the saved data
109
+ delete savedDataObj[storageKey];
110
+ localStorage.setItem('lyrics_analyzer_data', JSON.stringify(savedDataObj));
111
+ }
107
112
  }
108
113
  } catch (error) {
109
- console.error('Failed to parse saved data:', error)
110
- localStorage.removeItem('lyrics_analyzer_data')
114
+ console.error('Failed to parse saved data:', error);
115
+ // Remove only this song's data
116
+ delete savedDataObj[storageKey];
117
+ localStorage.setItem('lyrics_analyzer_data', JSON.stringify(savedDataObj));
111
118
  }
112
119
  }
113
- }, [initialData.transcribed_text])
120
+ }, [initialData.transcribed_text]);
114
121
 
115
122
  // Save to local storage whenever data changes
116
123
  useEffect(() => {
117
124
  if (!isReadOnly) {
118
- localStorage.setItem('lyrics_analyzer_data', JSON.stringify(data))
125
+ const storageKey = generateStorageKey(initialData.transcribed_text);
126
+ const savedDataStr = localStorage.getItem('lyrics_analyzer_data');
127
+ const savedDataObj = savedDataStr ? JSON.parse(savedDataStr) : {};
128
+
129
+ savedDataObj[storageKey] = data;
130
+ localStorage.setItem('lyrics_analyzer_data', JSON.stringify(savedDataObj));
119
131
  }
120
- }, [data, isReadOnly])
132
+ }, [data, isReadOnly, initialData.transcribed_text]);
121
133
 
122
- // Add keyboard event handlers
134
+ // Update keyboard event handler
123
135
  useEffect(() => {
124
136
  const handleKeyDown = (e: KeyboardEvent) => {
125
- // Ignore if user is typing in an input or textarea
126
137
  if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
127
138
  return
128
139
  }
@@ -133,9 +144,9 @@ export default function LyricsAnalyzer({ data: initialData, onFileLoad, apiClien
133
144
  } else if (e.key === 'Meta') {
134
145
  setIsCtrlPressed(true)
135
146
  } else if (e.key === ' ' || e.code === 'Space') {
136
- e.preventDefault() // Prevent page scroll
137
- if ((window as any).toggleAudioPlayback) {
138
- (window as any).toggleAudioPlayback()
147
+ e.preventDefault()
148
+ if (window.toggleAudioPlayback) {
149
+ window.toggleAudioPlayback()
139
150
  }
140
151
  }
141
152
  }
@@ -183,63 +194,45 @@ export default function LyricsAnalyzer({ data: initialData, onFileLoad, apiClien
183
194
 
184
195
  const handleWordClick = useCallback((info: WordClickInfo) => {
185
196
  if (effectiveMode === 'edit') {
186
- let currentPosition = 0
187
- const segmentIndex = data.corrected_segments.findIndex(segment => {
188
- if (info.wordIndex >= currentPosition &&
189
- info.wordIndex < currentPosition + segment.words.length) {
190
- return true
191
- }
192
- currentPosition += segment.words.length
193
- return false
194
- })
197
+ const segment = data.corrected_segments.find(segment =>
198
+ segment.words.some(word => word.id === info.word_id)
199
+ )
195
200
 
196
- if (segmentIndex !== -1) {
201
+ if (segment) {
202
+ const segmentIndex = data.corrected_segments.indexOf(segment)
197
203
  setEditModalSegment({
198
- segment: data.corrected_segments[segmentIndex],
204
+ segment,
199
205
  index: segmentIndex,
200
206
  originalSegment: originalData.corrected_segments[segmentIndex]
201
207
  })
202
208
  }
203
209
  } else {
204
- // Existing word click handling for other modes...
210
+ // Update flash handling for anchors/gaps
205
211
  if (info.type === 'anchor' && info.anchor) {
206
212
  handleFlash('word', {
207
213
  type: 'anchor',
208
- transcriptionIndex: info.anchor.transcription_position,
209
- transcriptionLength: info.anchor.length,
210
- referenceIndices: info.anchor.reference_positions,
211
- referenceLength: info.anchor.length
214
+ word_ids: info.anchor.word_ids,
215
+ reference_word_ids: info.anchor.reference_word_ids
212
216
  })
213
217
  } else if (info.type === 'gap' && info.gap) {
214
218
  handleFlash('word', {
215
219
  type: 'gap',
216
- transcriptionIndex: info.gap.transcription_position,
217
- transcriptionLength: info.gap.length,
218
- referenceIndices: {},
219
- referenceLength: info.gap.length
220
+ word_ids: info.gap.word_ids
220
221
  })
221
222
  }
222
223
  }
223
224
  }, [effectiveMode, data.corrected_segments, handleFlash, originalData.corrected_segments])
224
225
 
225
226
  const handleUpdateSegment = useCallback((updatedSegment: LyricsSegment) => {
226
- console.log('LyricsAnalyzer - handleUpdateSegment called:', {
227
- editModalSegment,
228
- updatedSegment,
229
- currentSegmentsCount: data.corrected_segments.length
230
- })
231
-
232
- if (!editModalSegment) {
233
- console.warn('LyricsAnalyzer - No editModalSegment found')
234
- return
235
- }
227
+ if (!editModalSegment) return
236
228
 
237
229
  const newData = { ...data }
238
- console.log('LyricsAnalyzer - Before update:', {
239
- segmentIndex: editModalSegment.index,
240
- oldText: newData.corrected_segments[editModalSegment.index].text,
241
- newText: updatedSegment.text
242
- })
230
+
231
+ // Ensure new words have IDs
232
+ updatedSegment.words = updatedSegment.words.map(word => ({
233
+ ...word,
234
+ id: word.id || nanoid()
235
+ }))
243
236
 
244
237
  newData.corrected_segments[editModalSegment.index] = updatedSegment
245
238
 
@@ -248,34 +241,37 @@ export default function LyricsAnalyzer({ data: initialData, onFileLoad, apiClien
248
241
  .map(segment => segment.text)
249
242
  .join('\n')
250
243
 
251
- console.log('LyricsAnalyzer - After update:', {
252
- segmentsCount: newData.corrected_segments.length,
253
- updatedText: newData.corrected_text
254
- })
255
-
256
244
  setData(newData)
257
- setEditModalSegment(null) // Close the modal
245
+ setEditModalSegment(null)
258
246
  }, [data, editModalSegment])
259
247
 
260
248
  const handleDeleteSegment = useCallback((segmentIndex: number) => {
261
- console.log('LyricsAnalyzer - handleDeleteSegment called:', {
262
- segmentIndex,
263
- currentSegmentsCount: data.corrected_segments.length
264
- })
265
-
266
249
  const newData = { ...data }
250
+ const deletedSegment = newData.corrected_segments[segmentIndex]
251
+
252
+ // Remove segment
267
253
  newData.corrected_segments = newData.corrected_segments.filter((_, index) => index !== segmentIndex)
268
254
 
255
+ // Update anchor and gap sequences to remove references to deleted words
256
+ newData.anchor_sequences = newData.anchor_sequences.map(anchor => ({
257
+ ...anchor,
258
+ word_ids: anchor.word_ids.filter(id =>
259
+ !deletedSegment.words.some(word => word.id === id)
260
+ )
261
+ }))
262
+
263
+ newData.gap_sequences = newData.gap_sequences.map(gap => ({
264
+ ...gap,
265
+ word_ids: gap.word_ids.filter(id =>
266
+ !deletedSegment.words.some(word => word.id === id)
267
+ )
268
+ }))
269
+
269
270
  // Update corrected_text
270
271
  newData.corrected_text = newData.corrected_segments
271
272
  .map(segment => segment.text)
272
273
  .join('\n')
273
274
 
274
- console.log('LyricsAnalyzer - After delete:', {
275
- segmentsCount: newData.corrected_segments.length,
276
- updatedText: newData.corrected_text
277
- })
278
-
279
275
  setData(newData)
280
276
  }, [data])
281
277
 
@@ -302,21 +298,27 @@ export default function LyricsAnalyzer({ data: initialData, onFileLoad, apiClien
302
298
  }
303
299
  }, [apiClient, data])
304
300
 
301
+ // Update play segment handler
305
302
  const handlePlaySegment = useCallback((startTime: number) => {
306
- // Access the globally exposed seekAndPlay method
307
- if ((window as any).seekAndPlayAudio) {
308
- (window as any).seekAndPlayAudio(startTime)
303
+ if (window.seekAndPlayAudio) {
304
+ window.seekAndPlayAudio(startTime)
309
305
  }
310
306
  }, [])
311
307
 
312
308
  const handleResetCorrections = useCallback(() => {
313
309
  if (window.confirm('Are you sure you want to reset all corrections? This cannot be undone.')) {
314
- // Clear local storage
315
- localStorage.removeItem('lyrics_analyzer_data')
310
+ const storageKey = generateStorageKey(initialData.transcribed_text);
311
+ const savedDataStr = localStorage.getItem('lyrics_analyzer_data');
312
+ const savedDataObj = savedDataStr ? JSON.parse(savedDataStr) : {};
313
+
314
+ // Remove only this song's data
315
+ delete savedDataObj[storageKey];
316
+ localStorage.setItem('lyrics_analyzer_data', JSON.stringify(savedDataObj));
317
+
316
318
  // Reset data to initial state
317
- setData(JSON.parse(JSON.stringify(initialData)))
319
+ setData(JSON.parse(JSON.stringify(initialData)));
318
320
  }
319
- }, [initialData])
321
+ }, [initialData]);
320
322
 
321
323
  return (
322
324
  <Box>
@@ -351,58 +353,60 @@ export default function LyricsAnalyzer({ data: initialData, onFileLoad, apiClien
351
353
  )}
352
354
  </Box>
353
355
 
354
- <Box sx={{ mb: 3 }}>
355
- <AudioPlayer
356
- apiClient={apiClient}
357
- onTimeUpdate={setCurrentAudioTime}
358
- />
359
- </Box>
360
-
361
356
  <Box sx={{ mb: 3 }}>
362
357
  <CorrectionMetrics
363
358
  // Anchor metrics
364
359
  anchorCount={data.metadata.anchor_sequences_count}
365
- multiSourceAnchors={data.anchor_sequences.filter(anchor =>
366
- Object.keys(anchor.reference_positions).length > 1).length}
367
- singleSourceMatches={{
368
- spotify: data.anchor_sequences.filter(anchor =>
369
- Object.keys(anchor.reference_positions).length === 1 &&
370
- 'spotify' in anchor.reference_positions).length,
371
- genius: data.anchor_sequences.filter(anchor =>
372
- Object.keys(anchor.reference_positions).length === 1 &&
373
- 'genius' in anchor.reference_positions).length
374
- }}
360
+ multiSourceAnchors={data.anchor_sequences?.filter(anchor =>
361
+ // Add null checks
362
+ anchor?.reference_word_ids &&
363
+ Object.keys(anchor.reference_word_ids || {}).length > 1
364
+ ).length ?? 0}
365
+ anchorWordCount={data.anchor_sequences?.reduce((sum, anchor) =>
366
+ sum + (anchor.length || 0), 0) ?? 0}
375
367
  // Gap metrics
376
- correctedGapCount={data.gap_sequences.filter(gap =>
377
- gap.corrections?.length > 0).length}
378
- uncorrectedGapCount={data.gap_sequences.filter(gap =>
379
- !gap.corrections?.length).length}
368
+ correctedGapCount={data.gap_sequences?.filter(gap =>
369
+ gap.corrections?.length > 0).length ?? 0}
370
+ uncorrectedGapCount={data.gap_sequences?.filter(gap =>
371
+ !gap.corrections?.length).length ?? 0}
380
372
  uncorrectedGaps={data.gap_sequences
381
- .filter(gap => !gap.corrections?.length)
373
+ ?.filter(gap => !gap.corrections?.length)
382
374
  .map(gap => ({
383
- position: gap.transcription_position,
375
+ position: gap.word_ids[0],
384
376
  length: gap.length
385
- }))}
377
+ })) ?? []}
386
378
  // Correction details
387
- replacedCount={data.gap_sequences.reduce((count, gap) =>
388
- count + (gap.corrections?.filter(c => !c.is_deletion && !c.split_total).length ?? 0), 0)}
389
- addedCount={data.gap_sequences.reduce((count, gap) =>
390
- count + (gap.corrections?.filter(c => c.split_total).length ?? 0), 0)}
391
- deletedCount={data.gap_sequences.reduce((count, gap) =>
392
- count + (gap.corrections?.filter(c => c.is_deletion).length ?? 0), 0)}
379
+ replacedCount={data.gap_sequences?.reduce((count, gap) =>
380
+ count + (gap.corrections?.filter(c => !c.is_deletion && !c.split_total).length ?? 0), 0) ?? 0}
381
+ addedCount={data.gap_sequences?.reduce((count, gap) =>
382
+ count + (gap.corrections?.filter(c => c.split_total).length ?? 0), 0) ?? 0}
383
+ deletedCount={data.gap_sequences?.reduce((count, gap) =>
384
+ count + (gap.corrections?.filter(c => c.is_deletion).length ?? 0), 0) ?? 0}
393
385
  onMetricClick={{
394
386
  anchor: () => handleFlash('anchor'),
395
387
  corrected: () => handleFlash('corrected'),
396
388
  uncorrected: () => handleFlash('uncorrected')
397
389
  }}
390
+ totalWords={data.metadata.total_words}
398
391
  />
399
392
  </Box>
400
393
 
401
- <Box sx={{ mb: 3 }}>
394
+ <Box sx={{
395
+ display: 'flex',
396
+ flexDirection: isMobile ? 'column' : 'row',
397
+ gap: 5,
398
+ alignItems: 'flex-start',
399
+ justifyContent: 'flex-start',
400
+ mb: 3
401
+ }}>
402
402
  <ModeSelector
403
403
  effectiveMode={effectiveMode}
404
404
  onChange={setInteractionMode}
405
405
  />
406
+ <AudioPlayer
407
+ apiClient={apiClient}
408
+ onTimeUpdate={setCurrentAudioTime}
409
+ />
406
410
  </Box>
407
411
 
408
412
  <Grid container spacing={2} direction={isMobile ? 'column' : 'row'}>
@@ -8,7 +8,6 @@ import { HighlightedText } from './shared/components/HighlightedText'
8
8
  export default function ReferenceView({
9
9
  referenceTexts,
10
10
  anchors,
11
- gaps,
12
11
  onElementClick,
13
12
  onWordClick,
14
13
  flashingType,
@@ -18,6 +17,12 @@ export default function ReferenceView({
18
17
  highlightInfo,
19
18
  mode
20
19
  }: ReferenceViewProps) {
20
+ // Get available sources from referenceTexts object
21
+ const availableSources = useMemo(() =>
22
+ Object.keys(referenceTexts) as Array<string>,
23
+ [referenceTexts]
24
+ )
25
+
21
26
  const { linePositions } = useMemo(() =>
22
27
  calculateReferenceLinePositions(
23
28
  corrected_segments,
@@ -36,13 +41,13 @@ export default function ReferenceView({
36
41
  <SourceSelector
37
42
  currentSource={currentSource}
38
43
  onSourceChange={onSourceChange}
44
+ availableSources={availableSources}
39
45
  />
40
46
  </Box>
41
47
  <Box sx={{ display: 'flex', flexDirection: 'column' }}>
42
48
  <HighlightedText
43
49
  text={referenceTexts[currentSource]}
44
50
  anchors={anchors}
45
- gaps={gaps}
46
51
  onElementClick={onElementClick}
47
52
  onWordClick={onWordClick}
48
53
  flashingType={flashingType}
@@ -26,8 +26,8 @@ interface DiffResult {
26
26
  type: 'added' | 'removed' | 'modified'
27
27
  path: string
28
28
  segmentIndex?: number
29
- oldValue?: any
30
- newValue?: any
29
+ oldValue?: string
30
+ newValue?: string
31
31
  wordChanges?: DiffResult[]
32
32
  }
33
33
 
@@ -58,13 +58,13 @@ export default function ReviewChangesModal({
58
58
 
59
59
  const wordChanges: DiffResult[] = []
60
60
 
61
- // Compare word-level changes
62
- segment.words.forEach((word, wordIndex) => {
63
- const updatedWord = updatedSegment.words[wordIndex]
61
+ // Compare word-level changes using word IDs
62
+ segment.words.forEach((word) => {
63
+ const updatedWord = updatedSegment.words.find(w => w.id === word.id)
64
64
  if (!updatedWord) {
65
65
  wordChanges.push({
66
66
  type: 'removed',
67
- path: `Word ${wordIndex}`,
67
+ path: `Word ${word.id}`,
68
68
  oldValue: `"${word.text}" (${word.start_time.toFixed(4)} - ${word.end_time.toFixed(4)})`
69
69
  })
70
70
  return
@@ -75,7 +75,7 @@ export default function ReviewChangesModal({
75
75
  Math.abs(word.end_time - updatedWord.end_time) > 0.0001) {
76
76
  wordChanges.push({
77
77
  type: 'modified',
78
- path: `Word ${wordIndex}`,
78
+ path: `Word ${word.id}`,
79
79
  oldValue: `"${word.text}" (${word.start_time.toFixed(4)} - ${word.end_time.toFixed(4)})`,
80
80
  newValue: `"${updatedWord.text}" (${updatedWord.start_time.toFixed(4)} - ${updatedWord.end_time.toFixed(4)})`
81
81
  })
@@ -83,16 +83,15 @@ export default function ReviewChangesModal({
83
83
  })
84
84
 
85
85
  // Check for added words
86
- if (updatedSegment.words.length > segment.words.length) {
87
- for (let i = segment.words.length; i < updatedSegment.words.length; i++) {
88
- const word = updatedSegment.words[i]
86
+ updatedSegment.words.forEach((word) => {
87
+ if (!segment.words.find(w => w.id === word.id)) {
89
88
  wordChanges.push({
90
89
  type: 'added',
91
- path: `Word ${i}`,
90
+ path: `Word ${word.id}`,
92
91
  newValue: `"${word.text}" (${word.start_time.toFixed(4)} - ${word.end_time.toFixed(4)})`
93
92
  })
94
93
  }
95
- }
94
+ })
96
95
 
97
96
  if (segment.text !== updatedSegment.text ||
98
97
  segment.start_time !== updatedSegment.start_time ||
@@ -109,6 +108,19 @@ export default function ReviewChangesModal({
109
108
  }
110
109
  })
111
110
 
111
+ // Check for added segments
112
+ if (updatedData.corrected_segments.length > originalData.corrected_segments.length) {
113
+ for (let i = originalData.corrected_segments.length; i < updatedData.corrected_segments.length; i++) {
114
+ const segment = updatedData.corrected_segments[i]
115
+ diffs.push({
116
+ type: 'added',
117
+ path: `Segment ${i}`,
118
+ segmentIndex: i,
119
+ newValue: `"${segment.text}" (${segment.start_time.toFixed(4)} - ${segment.end_time.toFixed(4)})`
120
+ })
121
+ }
122
+ }
123
+
112
124
  return diffs
113
125
  }, [originalData, updatedData])
114
126
 
@@ -48,9 +48,6 @@ export default function TranscriptionView({
48
48
  }: TranscriptionViewProps) {
49
49
  const [selectedSegmentIndex, setSelectedSegmentIndex] = useState<number | null>(null)
50
50
 
51
- // Keep track of global word position
52
- let globalWordPosition = 0
53
-
54
51
  return (
55
52
  <Paper sx={{ p: 2 }}>
56
53
  <Typography variant="h6" gutterBottom>
@@ -59,35 +56,32 @@ export default function TranscriptionView({
59
56
  <Box sx={{ display: 'flex', flexDirection: 'column' }}>
60
57
  {data.corrected_segments.map((segment, segmentIndex) => {
61
58
  // Convert segment words to TranscriptionWordPosition format
62
- const segmentWords: TranscriptionWordPosition[] = segment.words.map((word, idx) => {
63
- const position = globalWordPosition + idx
59
+ const segmentWords: TranscriptionWordPosition[] = segment.words.map(word => {
60
+ // Find if this word belongs to an anchor sequence
64
61
  const anchor = data.anchor_sequences.find(a =>
65
- position >= a.transcription_position &&
66
- position < a.transcription_position + a.length
62
+ a.word_ids.includes(word.id)
67
63
  )
64
+
65
+ // If not in an anchor, check if it belongs to a gap sequence
68
66
  const gap = !anchor ? data.gap_sequences.find(g =>
69
- position >= g.transcription_position &&
70
- position < g.transcription_position + g.length
67
+ g.word_ids.includes(word.id)
71
68
  ) : undefined
72
69
 
73
70
  return {
74
71
  word: {
72
+ id: word.id,
75
73
  text: word.text,
76
74
  start_time: word.start_time,
77
75
  end_time: word.end_time
78
76
  },
79
- position,
80
77
  type: anchor ? 'anchor' : gap ? 'gap' : 'other',
81
78
  sequence: anchor || gap,
82
79
  isInRange: true
83
80
  }
84
81
  })
85
82
 
86
- // Update global position counter for next segment
87
- globalWordPosition += segment.words.length
88
-
89
83
  return (
90
- <Box key={segmentIndex} sx={{ display: 'flex', alignItems: 'flex-start', width: '100%' }}>
84
+ <Box key={segment.id} sx={{ display: 'flex', alignItems: 'flex-start', width: '100%' }}>
91
85
  <SegmentControls>
92
86
  <SegmentIndex
93
87
  variant="body2"
@@ -109,7 +103,6 @@ export default function TranscriptionView({
109
103
  <HighlightedText
110
104
  wordPositions={segmentWords}
111
105
  anchors={data.anchor_sequences}
112
- gaps={data.gap_sequences}
113
106
  onElementClick={onElementClick}
114
107
  onWordClick={onWordClick}
115
108
  flashingType={flashingType}
@@ -5,7 +5,7 @@ import { ModalContent } from './LyricsAnalyzer'
5
5
 
6
6
  interface WordEditControlsProps {
7
7
  content: ModalContent
8
- onUpdateCorrection?: (position: number, updatedWords: string[]) => void
8
+ onUpdateCorrection?: (wordId: string, updatedWords: string[]) => void
9
9
  onClose: () => void
10
10
  }
11
11
 
@@ -47,13 +47,13 @@ export default function WordEditControls({ content, onUpdateCorrection, onClose
47
47
 
48
48
  const handleDelete = () => {
49
49
  if (!onUpdateCorrection) return
50
- onUpdateCorrection(content.data.position, [])
50
+ onUpdateCorrection(content.data.wordId, [])
51
51
  onClose()
52
52
  }
53
53
 
54
54
  const handleSaveEdit = () => {
55
55
  if (onUpdateCorrection) {
56
- onUpdateCorrection(content.data.position, [editedWord])
56
+ onUpdateCorrection(content.data.wordId, [editedWord])
57
57
  }
58
58
  onClose()
59
59
  }