lyrics-transcriber 0.37.0__py3-none-any.whl → 0.40.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 (29) hide show
  1. lyrics_transcriber/correction/handlers/extend_anchor.py +13 -2
  2. lyrics_transcriber/correction/handlers/word_operations.py +8 -2
  3. lyrics_transcriber/frontend/dist/assets/index-DKnNJHRK.js +26696 -0
  4. lyrics_transcriber/frontend/dist/assets/index-DKnNJHRK.js.map +1 -0
  5. lyrics_transcriber/frontend/dist/index.html +1 -1
  6. lyrics_transcriber/frontend/package.json +3 -2
  7. lyrics_transcriber/frontend/src/components/EditModal.tsx +1 -0
  8. lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +36 -13
  9. lyrics_transcriber/frontend/src/components/ReferenceView.tsx +41 -1
  10. lyrics_transcriber/frontend/src/components/ReviewChangesModal.tsx +48 -16
  11. lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +71 -16
  12. lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +11 -7
  13. lyrics_transcriber/frontend/src/components/shared/components/HighlightedText.tsx +45 -12
  14. lyrics_transcriber/frontend/src/components/shared/hooks/useWordClick.ts +83 -19
  15. lyrics_transcriber/frontend/src/components/shared/types.ts +3 -0
  16. lyrics_transcriber/frontend/src/components/shared/utils/initializeDataWithIds.tsx +65 -9
  17. lyrics_transcriber/frontend/vite.config.js +4 -0
  18. lyrics_transcriber/frontend/vite.config.ts +4 -0
  19. lyrics_transcriber/lyrics/genius.py +41 -12
  20. lyrics_transcriber/output/cdg.py +106 -29
  21. lyrics_transcriber/output/cdgmaker/composer.py +822 -528
  22. lyrics_transcriber/output/lrc_to_cdg.py +61 -0
  23. lyrics_transcriber/review/server.py +10 -12
  24. {lyrics_transcriber-0.37.0.dist-info → lyrics_transcriber-0.40.0.dist-info}/METADATA +3 -2
  25. {lyrics_transcriber-0.37.0.dist-info → lyrics_transcriber-0.40.0.dist-info}/RECORD +28 -26
  26. {lyrics_transcriber-0.37.0.dist-info → lyrics_transcriber-0.40.0.dist-info}/entry_points.txt +1 -0
  27. lyrics_transcriber/frontend/dist/assets/index-BNNbsbVN.js +0 -182
  28. {lyrics_transcriber-0.37.0.dist-info → lyrics_transcriber-0.40.0.dist-info}/LICENSE +0 -0
  29. {lyrics_transcriber-0.37.0.dist-info → lyrics_transcriber-0.40.0.dist-info}/WHEEL +0 -0
@@ -5,7 +5,7 @@
5
5
  <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
7
  <title>Lyrics Transcriber Analyzer</title>
8
- <script type="module" crossorigin src="/assets/index-BNNbsbVN.js"></script>
8
+ <script type="module" crossorigin src="/assets/index-DKnNJHRK.js"></script>
9
9
  </head>
10
10
  <body>
11
11
  <div id="root"></div>
@@ -6,10 +6,11 @@
6
6
  "type": "module",
7
7
  "scripts": {
8
8
  "dev": "vite",
9
- "build": "tsc -b && vite build",
9
+ "build": "tsc -b && vite build --mode development",
10
+ "build-prod": "tsc -b && vite build",
10
11
  "lint": "eslint .",
11
12
  "preview": "vite preview",
12
- "predeploy": "npm run build",
13
+ "predeploy": "npm run build-prod",
13
14
  "deploy": "gh-pages -d dist"
14
15
  },
15
16
  "dependencies": {
@@ -289,6 +289,7 @@ export default function EditModal({
289
289
  updateSegment(newWords)
290
290
  }}
291
291
  currentTime={currentTime}
292
+ onPlaySegment={onPlaySegment}
292
293
  />
293
294
  </Box>
294
295
 
@@ -91,7 +91,6 @@ export default function LyricsAnalyzer({ data: initialData, onFileLoad, apiClien
91
91
 
92
92
  // Add local storage handling
93
93
  useEffect(() => {
94
- // On mount, try to load saved data
95
94
  const storageKey = generateStorageKey(initialData.transcribed_text);
96
95
  const savedDataStr = localStorage.getItem('lyrics_analyzer_data');
97
96
  const savedDataObj = savedDataStr ? JSON.parse(savedDataStr) : {};
@@ -99,25 +98,42 @@ export default function LyricsAnalyzer({ data: initialData, onFileLoad, apiClien
99
98
  if (savedDataObj[storageKey]) {
100
99
  try {
101
100
  const parsed = savedDataObj[storageKey];
102
- // Verify it's the same song (extra safety check)
103
101
  if (parsed.transcribed_text === initialData.transcribed_text) {
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');
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?')) {
106
124
  setData(parsed);
107
- } else {
108
- // User declined to restore - remove the saved data
125
+ } else if (!hasChanges) {
109
126
  delete savedDataObj[storageKey];
110
127
  localStorage.setItem('lyrics_analyzer_data', JSON.stringify(savedDataObj));
111
128
  }
112
129
  }
113
130
  } catch (error) {
114
131
  console.error('Failed to parse saved data:', error);
115
- // Remove only this song's data
116
132
  delete savedDataObj[storageKey];
117
133
  localStorage.setItem('lyrics_analyzer_data', JSON.stringify(savedDataObj));
118
134
  }
119
135
  }
120
- }, [initialData.transcribed_text]);
136
+ }, [initialData]);
121
137
 
122
138
  // Save to local storage whenever data changes
123
139
  useEffect(() => {
@@ -315,8 +331,15 @@ export default function LyricsAnalyzer({ data: initialData, onFileLoad, apiClien
315
331
  delete savedDataObj[storageKey];
316
332
  localStorage.setItem('lyrics_analyzer_data', JSON.stringify(savedDataObj));
317
333
 
318
- // Reset data to initial state
319
- setData(JSON.parse(JSON.stringify(initialData)));
334
+ // Reset data to initial state with proper initialization
335
+ const freshData = initializeDataWithIds(JSON.parse(JSON.stringify(initialData)));
336
+ setData(freshData);
337
+
338
+ // Reset any UI state that might affect highlights
339
+ setModalContent(null);
340
+ setFlashingType(null);
341
+ setHighlightInfo(null);
342
+ setInteractionMode('details');
320
343
  }
321
344
  }, [initialData]);
322
345
 
@@ -370,10 +393,10 @@ export default function LyricsAnalyzer({ data: initialData, onFileLoad, apiClien
370
393
  uncorrectedGapCount={data.gap_sequences?.filter(gap =>
371
394
  !gap.corrections?.length).length ?? 0}
372
395
  uncorrectedGaps={data.gap_sequences
373
- ?.filter(gap => !gap.corrections?.length)
396
+ ?.filter(gap => !gap.corrections?.length && gap.word_ids)
374
397
  .map(gap => ({
375
- position: gap.word_ids[0],
376
- length: gap.length
398
+ position: gap.word_ids?.[0] ?? '',
399
+ length: gap.length ?? 0
377
400
  })) ?? []}
378
401
  // Correction details
379
402
  replacedCount={data.gap_sequences?.reduce((count, gap) =>
@@ -4,6 +4,7 @@ import { ReferenceViewProps } from './shared/types'
4
4
  import { calculateReferenceLinePositions } from './shared/utils/referenceLineCalculator'
5
5
  import { SourceSelector } from './shared/components/SourceSelector'
6
6
  import { HighlightedText } from './shared/components/HighlightedText'
7
+ import { WordCorrection } from '@/types'
7
8
 
8
9
  export default function ReferenceView({
9
10
  referenceTexts,
@@ -15,7 +16,8 @@ export default function ReferenceView({
15
16
  currentSource,
16
17
  onSourceChange,
17
18
  highlightInfo,
18
- mode
19
+ mode,
20
+ gaps
19
21
  }: ReferenceViewProps) {
20
22
  // Get available sources from referenceTexts object
21
23
  const availableSources = useMemo(() =>
@@ -32,6 +34,42 @@ export default function ReferenceView({
32
34
  [corrected_segments, anchors, currentSource]
33
35
  )
34
36
 
37
+ // Create a mapping of reference words to their corrections
38
+ const referenceCorrections = useMemo(() => {
39
+ const corrections = new Map<string, string>();
40
+
41
+ console.log('Building referenceCorrections map:', {
42
+ gapsCount: gaps.length,
43
+ currentSource,
44
+ });
45
+
46
+ gaps.forEach(gap => {
47
+ gap.corrections.forEach((correction: WordCorrection) => {
48
+ // Get the reference position for this correction
49
+ const referencePosition = correction.reference_positions?.[currentSource];
50
+
51
+ if (typeof referencePosition === 'number') {
52
+ const wordId = `${currentSource}-word-${referencePosition}`;
53
+ corrections.set(wordId, correction.corrected_word);
54
+
55
+ console.log('Adding correction mapping:', {
56
+ wordId,
57
+ correctedWord: correction.corrected_word,
58
+ referencePosition,
59
+ correction
60
+ });
61
+ }
62
+ });
63
+ });
64
+
65
+ console.log('Final referenceCorrections map:', {
66
+ size: corrections.size,
67
+ entries: Array.from(corrections.entries())
68
+ });
69
+
70
+ return corrections;
71
+ }, [gaps, currentSource]);
72
+
35
73
  return (
36
74
  <Paper sx={{ p: 2 }}>
37
75
  <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
@@ -56,6 +94,8 @@ export default function ReferenceView({
56
94
  isReference={true}
57
95
  currentSource={currentSource}
58
96
  linePositions={linePositions}
97
+ referenceCorrections={referenceCorrections}
98
+ gaps={gaps}
59
99
  />
60
100
  </Box>
61
101
  </Paper>
@@ -31,6 +31,35 @@ interface DiffResult {
31
31
  wordChanges?: DiffResult[]
32
32
  }
33
33
 
34
+ // Add interfaces for the word and segment structures
35
+ interface Word {
36
+ text: string
37
+ start_time: number
38
+ end_time: number
39
+ id?: string
40
+ }
41
+
42
+ interface Segment {
43
+ text: string
44
+ start_time: number
45
+ end_time: number
46
+ words: Word[]
47
+ id?: string
48
+ }
49
+
50
+ const normalizeWordForComparison = (word: Word): Omit<Word, 'id'> => ({
51
+ text: word.text,
52
+ start_time: word.start_time,
53
+ end_time: word.end_time
54
+ })
55
+
56
+ const normalizeSegmentForComparison = (segment: Segment): Omit<Segment, 'id'> => ({
57
+ text: segment.text,
58
+ start_time: segment.start_time,
59
+ end_time: segment.end_time,
60
+ words: segment.words.map(normalizeWordForComparison)
61
+ })
62
+
34
63
  export default function ReviewChangesModal({
35
64
  open,
36
65
  onClose,
@@ -44,27 +73,29 @@ export default function ReviewChangesModal({
44
73
  const diffs: DiffResult[] = []
45
74
 
46
75
  // Compare corrected segments
47
- originalData.corrected_segments.forEach((segment, index) => {
76
+ originalData.corrected_segments.forEach((originalSegment, index) => {
48
77
  const updatedSegment = updatedData.corrected_segments[index]
49
78
  if (!updatedSegment) {
50
79
  diffs.push({
51
80
  type: 'removed',
52
81
  path: `Segment ${index}`,
53
82
  segmentIndex: index,
54
- oldValue: segment.text
83
+ oldValue: originalSegment.text
55
84
  })
56
85
  return
57
86
  }
58
87
 
88
+ const normalizedOriginal = normalizeSegmentForComparison(originalSegment)
89
+ const normalizedUpdated = normalizeSegmentForComparison(updatedSegment)
59
90
  const wordChanges: DiffResult[] = []
60
91
 
61
- // Compare word-level changes using word IDs
62
- segment.words.forEach((word) => {
63
- const updatedWord = updatedSegment.words.find(w => w.id === word.id)
92
+ // Compare word-level changes based on position rather than IDs
93
+ normalizedOriginal.words.forEach((word: Omit<Word, 'id'>, wordIndex: number) => {
94
+ const updatedWord = normalizedUpdated.words[wordIndex]
64
95
  if (!updatedWord) {
65
96
  wordChanges.push({
66
97
  type: 'removed',
67
- path: `Word ${word.id}`,
98
+ path: `Word ${wordIndex}`,
68
99
  oldValue: `"${word.text}" (${word.start_time.toFixed(4)} - ${word.end_time.toFixed(4)})`
69
100
  })
70
101
  return
@@ -75,7 +106,7 @@ export default function ReviewChangesModal({
75
106
  Math.abs(word.end_time - updatedWord.end_time) > 0.0001) {
76
107
  wordChanges.push({
77
108
  type: 'modified',
78
- path: `Word ${word.id}`,
109
+ path: `Word ${wordIndex}`,
79
110
  oldValue: `"${word.text}" (${word.start_time.toFixed(4)} - ${word.end_time.toFixed(4)})`,
80
111
  newValue: `"${updatedWord.text}" (${updatedWord.start_time.toFixed(4)} - ${updatedWord.end_time.toFixed(4)})`
81
112
  })
@@ -83,26 +114,27 @@ export default function ReviewChangesModal({
83
114
  })
84
115
 
85
116
  // Check for added words
86
- updatedSegment.words.forEach((word) => {
87
- if (!segment.words.find(w => w.id === word.id)) {
117
+ if (normalizedUpdated.words.length > normalizedOriginal.words.length) {
118
+ for (let i = normalizedOriginal.words.length; i < normalizedUpdated.words.length; i++) {
119
+ const word = normalizedUpdated.words[i]
88
120
  wordChanges.push({
89
121
  type: 'added',
90
- path: `Word ${word.id}`,
122
+ path: `Word ${i}`,
91
123
  newValue: `"${word.text}" (${word.start_time.toFixed(4)} - ${word.end_time.toFixed(4)})`
92
124
  })
93
125
  }
94
- })
126
+ }
95
127
 
96
- if (segment.text !== updatedSegment.text ||
97
- segment.start_time !== updatedSegment.start_time ||
98
- segment.end_time !== updatedSegment.end_time ||
128
+ if (normalizedOriginal.text !== normalizedUpdated.text ||
129
+ Math.abs(normalizedOriginal.start_time - normalizedUpdated.start_time) > 0.0001 ||
130
+ Math.abs(normalizedOriginal.end_time - normalizedUpdated.end_time) > 0.0001 ||
99
131
  wordChanges.length > 0) {
100
132
  diffs.push({
101
133
  type: 'modified',
102
134
  path: `Segment ${index}`,
103
135
  segmentIndex: index,
104
- oldValue: `"${segment.text}" (${segment.start_time.toFixed(4)} - ${segment.end_time.toFixed(4)})`,
105
- newValue: `"${updatedSegment.text}" (${updatedSegment.start_time.toFixed(4)} - ${updatedSegment.end_time.toFixed(4)})`,
136
+ oldValue: `"${normalizedOriginal.text}" (${normalizedOriginal.start_time.toFixed(4)} - ${normalizedOriginal.end_time.toFixed(4)})`,
137
+ newValue: `"${normalizedUpdated.text}" (${normalizedUpdated.start_time.toFixed(4)} - ${normalizedUpdated.end_time.toFixed(4)})`,
106
138
  wordChanges: wordChanges.length > 0 ? wordChanges : undefined
107
139
  })
108
140
  }
@@ -8,11 +8,12 @@ interface TimelineEditorProps {
8
8
  endTime: number
9
9
  onWordUpdate: (index: number, updates: Partial<Word>) => void
10
10
  currentTime?: number
11
+ onPlaySegment?: (time: number) => void
11
12
  }
12
13
 
13
14
  const TimelineContainer = styled(Box)(({ theme }) => ({
14
15
  position: 'relative',
15
- height: '60px',
16
+ height: '80px',
16
17
  backgroundColor: theme.palette.grey[200],
17
18
  borderRadius: theme.shape.borderRadius,
18
19
  margin: theme.spacing(2, 0),
@@ -24,30 +25,38 @@ const TimelineRuler = styled(Box)(({ theme }) => ({
24
25
  top: 0,
25
26
  left: 0,
26
27
  right: 0,
27
- height: '16px',
28
+ height: '40px',
28
29
  borderBottom: `1px solid ${theme.palette.grey[300]}`,
30
+ cursor: 'pointer',
29
31
  }))
30
32
 
31
33
  const TimelineMark = styled(Box)(({ theme }) => ({
32
34
  position: 'absolute',
33
- top: '10px',
35
+ top: '20px',
34
36
  width: '1px',
35
- height: '6px',
36
- backgroundColor: theme.palette.grey[400],
37
+ height: '18px',
38
+ backgroundColor: theme.palette.grey[700],
39
+ '&.subsecond': {
40
+ top: '25px',
41
+ height: '13px',
42
+ backgroundColor: theme.palette.grey[500],
43
+ }
37
44
  }))
38
45
 
39
46
  const TimelineLabel = styled(Box)(({ theme }) => ({
40
47
  position: 'absolute',
41
- top: 0,
48
+ top: '5px',
42
49
  transform: 'translateX(-50%)',
43
- fontSize: '0.6rem',
44
- color: theme.palette.grey[600],
50
+ fontSize: '0.8rem',
51
+ color: theme.palette.text.primary,
52
+ fontWeight: 700,
53
+ backgroundColor: theme.palette.grey[200],
45
54
  }))
46
55
 
47
56
  const TimelineWord = styled(Box)(({ theme }) => ({
48
57
  position: 'absolute',
49
58
  height: '30px',
50
- top: '22px',
59
+ top: '40px',
51
60
  backgroundColor: theme.palette.primary.main,
52
61
  borderRadius: theme.shape.borderRadius,
53
62
  color: theme.palette.primary.contrastText,
@@ -57,6 +66,7 @@ const TimelineWord = styled(Box)(({ theme }) => ({
57
66
  display: 'flex',
58
67
  alignItems: 'center',
59
68
  fontSize: '0.875rem',
69
+ fontFamily: 'sans-serif',
60
70
  transition: 'background-color 0.1s ease',
61
71
  '&.highlighted': {
62
72
  backgroundColor: theme.palette.secondary.main,
@@ -75,7 +85,19 @@ const ResizeHandle = styled(Box)(({ theme }) => ({
75
85
  },
76
86
  }))
77
87
 
78
- export default function TimelineEditor({ words, startTime, endTime, onWordUpdate, currentTime = 0 }: TimelineEditorProps) {
88
+ // Add new styled component for the cursor
89
+ const TimelineCursor = styled(Box)(({ theme }) => ({
90
+ position: 'absolute',
91
+ top: 0,
92
+ width: '2px',
93
+ height: '100%', // Full height of container
94
+ backgroundColor: theme.palette.error.main, // Red color
95
+ pointerEvents: 'none', // Ensure it doesn't interfere with clicks
96
+ transition: 'left 0.1s linear', // Smooth movement
97
+ zIndex: 1, // Ensure it's above other elements
98
+ }))
99
+
100
+ export default function TimelineEditor({ words, startTime, endTime, onWordUpdate, currentTime = 0, onPlaySegment }: TimelineEditorProps) {
79
101
  const containerRef = useRef<HTMLDivElement>(null)
80
102
  const [dragState, setDragState] = useState<{
81
103
  wordIndex: number
@@ -142,15 +164,23 @@ export default function TimelineEditor({ words, startTime, endTime, onWordUpdate
142
164
  const startSecond = Math.floor(startTime)
143
165
  const endSecond = Math.ceil(endTime)
144
166
 
145
- for (let time = startSecond; time <= endSecond; time++) {
167
+ // Generate marks for each 0.1 second interval
168
+ for (let time = startSecond; time <= endSecond; time += 0.1) {
146
169
  if (time >= startTime && time <= endTime) {
147
170
  const position = timeToPosition(time)
171
+ const isFullSecond = Math.abs(time - Math.round(time)) < 0.001
172
+
148
173
  marks.push(
149
174
  <Box key={time}>
150
- <TimelineMark sx={{ left: `${position}%` }} />
151
- <TimelineLabel sx={{ left: `${position}%` }}>
152
- {time}s
153
- </TimelineLabel>
175
+ <TimelineMark
176
+ className={isFullSecond ? '' : 'subsecond'}
177
+ sx={{ left: `${position}%` }}
178
+ />
179
+ {isFullSecond && (
180
+ <TimelineLabel sx={{ left: `${position}%` }}>
181
+ {Math.round(time)}s
182
+ </TimelineLabel>
183
+ )}
154
184
  </Box>
155
185
  )
156
186
  }
@@ -266,6 +296,22 @@ export default function TimelineEditor({ words, startTime, endTime, onWordUpdate
266
296
  return currentTime >= word.start_time && currentTime <= word.end_time
267
297
  }
268
298
 
299
+ const handleTimelineClick = (e: React.MouseEvent) => {
300
+ const rect = containerRef.current?.getBoundingClientRect()
301
+ if (!rect || !onPlaySegment) return
302
+
303
+ const x = e.clientX - rect.left
304
+ const clickedPosition = (x / rect.width) * (endTime - startTime) + startTime
305
+
306
+ console.log('Timeline clicked:', {
307
+ x,
308
+ width: rect.width,
309
+ clickedTime: clickedPosition
310
+ })
311
+
312
+ onPlaySegment(clickedPosition)
313
+ }
314
+
269
315
  return (
270
316
  <TimelineContainer
271
317
  ref={containerRef}
@@ -273,9 +319,18 @@ export default function TimelineEditor({ words, startTime, endTime, onWordUpdate
273
319
  onMouseUp={handleMouseUp}
274
320
  onMouseLeave={handleMouseUp}
275
321
  >
276
- <TimelineRuler>
322
+ <TimelineRuler onClick={handleTimelineClick}>
277
323
  {generateTimelineMarks()}
278
324
  </TimelineRuler>
325
+
326
+ {/* Add cursor line */}
327
+ <TimelineCursor
328
+ sx={{
329
+ left: `${timeToPosition(currentTime)}%`,
330
+ display: currentTime >= startTime && currentTime <= endTime ? 'block' : 'none'
331
+ }}
332
+ />
333
+
279
334
  {words.map((word, index) => {
280
335
  const leftPosition = timeToPosition(word.start_time)
281
336
  const rightPosition = timeToPosition(word.end_time)
@@ -55,18 +55,21 @@ export default function TranscriptionView({
55
55
  </Typography>
56
56
  <Box sx={{ display: 'flex', flexDirection: 'column' }}>
57
57
  {data.corrected_segments.map((segment, segmentIndex) => {
58
- // Convert segment words to TranscriptionWordPosition format
59
58
  const segmentWords: TranscriptionWordPosition[] = segment.words.map(word => {
60
- // Find if this word belongs to an anchor sequence
61
- const anchor = data.anchor_sequences.find(a =>
62
- a.word_ids.includes(word.id)
59
+ const anchor = data.anchor_sequences?.find(a =>
60
+ a?.word_ids?.includes(word.id)
63
61
  )
64
62
 
65
63
  // If not in an anchor, check if it belongs to a gap sequence
66
- const gap = !anchor ? data.gap_sequences.find(g =>
67
- g.word_ids.includes(word.id)
64
+ const gap = !anchor ? data.gap_sequences?.find(g =>
65
+ g?.word_ids?.includes(word.id)
68
66
  ) : undefined
69
67
 
68
+ // Check if this specific word has been corrected
69
+ const isWordCorrected = gap?.corrections?.some(
70
+ correction => correction.word_id === word.id
71
+ )
72
+
70
73
  return {
71
74
  word: {
72
75
  id: word.id,
@@ -76,7 +79,8 @@ export default function TranscriptionView({
76
79
  },
77
80
  type: anchor ? 'anchor' : gap ? 'gap' : 'other',
78
81
  sequence: anchor || gap,
79
- isInRange: true
82
+ isInRange: true,
83
+ isCorrected: isWordCorrected
80
84
  }
81
85
  })
82
86
 
@@ -25,6 +25,8 @@ export interface HighlightedTextProps {
25
25
  preserveSegments?: boolean
26
26
  linePositions?: LinePosition[]
27
27
  currentTime?: number
28
+ referenceCorrections?: Map<string, string>
29
+ gaps?: GapSequence[]
28
30
  }
29
31
 
30
32
  export function HighlightedText({
@@ -40,14 +42,17 @@ export function HighlightedText({
40
42
  currentSource,
41
43
  preserveSegments = false,
42
44
  linePositions = [],
43
- currentTime = 0
45
+ currentTime = 0,
46
+ referenceCorrections = new Map(),
47
+ gaps = []
44
48
  }: HighlightedTextProps) {
45
49
  const { handleWordClick } = useWordClick({
46
50
  mode,
47
51
  onElementClick,
48
52
  onWordClick,
49
53
  isReference,
50
- currentSource
54
+ currentSource,
55
+ gaps
51
56
  })
52
57
 
53
58
  const shouldWordFlash = (wordPos: TranscriptionWordPosition | { word: string; id: string }): boolean => {
@@ -65,9 +70,14 @@ export function HighlightedText({
65
70
  (flashingType === 'anchor' && wordPos.type === 'anchor') ||
66
71
  (flashingType === 'corrected' && isCorrected) ||
67
72
  (flashingType === 'uncorrected' && wordPos.type === 'gap' && !isCorrected) ||
68
- (flashingType === 'word' && highlightInfo?.type === 'anchor' &&
69
- wordPos.type === 'anchor' && wordPos.sequence &&
70
- highlightInfo.word_ids?.includes(wordPos.word.id))
73
+ (flashingType === 'word' && (
74
+ // Handle anchor highlighting
75
+ (highlightInfo?.type === 'anchor' && wordPos.type === 'anchor' &&
76
+ wordPos.sequence && highlightInfo.word_ids?.includes(wordPos.word.id)) ||
77
+ // Handle gap highlighting - only highlight the specific word
78
+ (highlightInfo?.type === 'gap' && wordPos.type === 'gap' &&
79
+ highlightInfo.word_ids?.includes(wordPos.word.id))
80
+ ))
71
81
  )
72
82
  } else {
73
83
  // Handle reference word
@@ -77,10 +87,32 @@ export function HighlightedText({
77
87
  a?.reference_word_ids?.[currentSource]?.includes(wordPos.id)
78
88
  )
79
89
 
90
+ // Check if this word should flash as part of a gap
91
+ const shouldFlashGap = flashingType === 'word' &&
92
+ highlightInfo?.type === 'gap' &&
93
+ // Check if this reference word corresponds to the clicked word
94
+ gaps?.some(gap =>
95
+ gap.corrections.some(correction => {
96
+ // Only flash if this correction corresponds to the clicked word
97
+ if (!highlightInfo.word_ids?.includes(correction.word_id)) {
98
+ return false;
99
+ }
100
+
101
+ const refPosition = correction.reference_positions?.[currentSource];
102
+ const wordPosition = parseInt(wordPos.id.split('-').pop() || '', 10);
103
+ return typeof refPosition === 'number' && refPosition === wordPosition;
104
+ })
105
+ )
106
+
80
107
  return Boolean(
81
108
  (flashingType === 'anchor' && anchor) ||
82
- (flashingType === 'word' && highlightInfo?.type === 'anchor' &&
83
- highlightInfo.reference_word_ids?.[currentSource]?.includes(wordPos.id))
109
+ (flashingType === 'word' && (
110
+ // Handle anchor highlighting
111
+ (highlightInfo?.type === 'anchor' &&
112
+ highlightInfo.reference_word_ids?.[currentSource]?.includes(wordPos.id)) ||
113
+ // Handle gap highlighting
114
+ shouldFlashGap
115
+ ))
84
116
  )
85
117
  }
86
118
  }
@@ -103,8 +135,8 @@ export function HighlightedText({
103
135
  shouldFlash={shouldWordFlash(wordPos)}
104
136
  isCurrentlyPlaying={shouldHighlightWord(wordPos)}
105
137
  isAnchor={wordPos.type === 'anchor'}
106
- isCorrectedGap={wordPos.type === 'gap' && Boolean((wordPos.sequence as GapSequence)?.corrections?.length)}
107
- isUncorrectedGap={wordPos.type === 'gap' && !(wordPos.sequence as GapSequence)?.corrections?.length}
138
+ isCorrectedGap={wordPos.type === 'gap' && wordPos.isCorrected}
139
+ isUncorrectedGap={wordPos.type === 'gap' && !wordPos.isCorrected}
108
140
  onClick={() => handleWordClick(
109
141
  wordPos.word.text,
110
142
  wordPos.word.id,
@@ -183,22 +215,23 @@ export function HighlightedText({
183
215
  return <span key={`space-${lineIndex}-${wordIndex}`}> </span>
184
216
  }
185
217
 
186
- // Generate word ID based on position in the reference text
187
218
  const wordId = `${currentSource}-word-${wordCount}`
188
219
  wordCount++
189
220
 
190
- // Find if this word is part of any anchor sequence
191
221
  const anchor = currentSource ? anchors?.find(a =>
192
222
  a?.reference_word_ids?.[currentSource]?.includes(wordId)
193
223
  ) : undefined
194
224
 
225
+ // Check if this word has a correction
226
+ const hasCorrection = referenceCorrections.has(wordId)
227
+
195
228
  return (
196
229
  <Word
197
230
  key={wordId}
198
231
  word={word}
199
232
  shouldFlash={shouldWordFlash({ word, id: wordId })}
200
233
  isAnchor={Boolean(anchor)}
201
- isCorrectedGap={false}
234
+ isCorrectedGap={hasCorrection}
202
235
  isUncorrectedGap={false}
203
236
  onClick={() => handleWordClick(word, wordId, anchor, undefined)}
204
237
  />